Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions src/commands/list.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
use super::*;
use crate::workspace::{ProjectWithWorkspace, workspaces};
use crate::workspace::{
ProjectWithWorkspace, project_favorites_by_workspace, workspaces_with_client,
};

/// Marks a favorited project in the human listing. Favorites also sort to the
/// top of their workspace, so the glyph is a confirmation of the grouping
/// rather than the only signal — which keeps the listing readable where the
/// star cannot render.
const FAVORITE_MARKER: &str = "★ ";

/// List all projects in your Railway account
#[derive(Parser)]
Expand All @@ -13,26 +21,59 @@ pub async fn command(args: Args) -> Result<()> {
let configs = Configs::new()?;
let linked_project = configs.get_linked_project().await.ok();

let workspaces = workspaces().await?;
let client = GQLClient::new_authorized(&configs)?;
let workspaces = workspaces_with_client(&client, &configs).await?;

// Favorites are fetched per workspace and joined here rather than being
// selected alongside the projects: `projectFavorites` hangs off `Query`,
// not off a workspace. A caller the field refuses just gets empty lists,
// and the listing below renders exactly as it did before favorites.
let favorites = project_favorites_by_workspace(
&client,
&configs,
workspaces.iter().map(|w| w.id().to_string()).collect(),
)
.await;

let mut all_projects: Vec<ProjectWithWorkspace> = Vec::new();

for workspace in workspaces {
let workspace_favorites = favorites
.get(workspace.id())
.map(Vec::as_slice)
.unwrap_or_default();

if !args.json {
println!();
println!("{}", workspace.name().bold());

for project in workspace.projects() {
let ranked = workspace.projects_ranked(workspace_favorites);
// Only reserve the marker's width once this workspace actually has
// a favorite to show. With none — the degraded case for the tokens
// the favorites field refuses, and for an account that has starred
// nothing — every line stays byte-for-byte what it was before
// favorites existed.
let reserve_marker_width = ranked.iter().any(|(_, is_favorite)| *is_favorite);

for (project, is_favorite) in ranked {
let project_name =
if Some(project.id()) == linked_project.as_ref().map(|p| p.project.as_str()) {
project.name().purple().bold()
} else {
project.name().white()
};
println!(" {project_name}");
if is_favorite {
println!(" {}{project_name}", FAVORITE_MARKER.yellow());
} else if reserve_marker_width {
let pad = " ".repeat(FAVORITE_MARKER.chars().count());
println!(" {pad}{project_name}");
} else {
println!(" {project_name}");
}
}
}

all_projects.extend(workspace.projects_with_workspace());
all_projects.extend(workspace.projects_with_workspace(workspace_favorites));
}

if args.json {
Expand Down
14 changes: 14 additions & 0 deletions src/gql/queries/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ pub struct TwoFactorInfo;
)]
pub struct UserProjects;

/// The favorited project ids for one workspace, for `railway list`.
///
/// Its own query rather than a field on `UserProjects`: favorites hang off
/// `Query`, not off a workspace, so they cannot be selected inside that
/// document — and they are optional decoration, which lets the caller drop
/// them when the API refuses without losing the project list itself.
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
query_path = "src/gql/queries/strings/ProjectFavorites.graphql",
response_derives = "Debug, Serialize, Clone"
)]
pub struct ProjectFavorites;

#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/gql/schema.json",
Expand Down
3 changes: 3 additions & 0 deletions src/gql/queries/strings/ProjectFavorites.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
query ProjectFavorites($workspaceId: String!) {
projectFavorites(workspaceId: $workspaceId)
}
2 changes: 1 addition & 1 deletion src/gql/schema.json

Large diffs are not rendered by default.

225 changes: 221 additions & 4 deletions src/workspace.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use chrono::{DateTime, Utc};
use futures_util::{StreamExt, stream};
use serde::Serialize;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;

use super::{
Expand Down Expand Up @@ -45,6 +46,51 @@ pub async fn workspaces_with_client(
Ok(workspaces)
}

/// `projectFavorites` takes a single workspace, so a listing that spans
/// workspaces is one request each. Bounded so an account with many workspaces
/// opens a handful of connections rather than one per workspace at once.
const FAVORITES_CONCURRENCY: usize = 6;

/// The authenticated user's favorited project ids, keyed by workspace id, in
/// the order the API returned them.
///
/// Favorites are decoration, so **every** failure degrades to "no favorites"
/// for that workspace rather than propagating. That is load-bearing: the field
/// requires a user principal, so it is refused outright for the
/// workspace-scoped and project tokens that `railway list` must keep serving,
/// and it can additionally be gated off by a feature flag. A caller that
/// cannot read favorites still gets exactly the listing it got before.
///
/// Errors are swallowed silently rather than warned about: on a project token
/// the refusal is the expected outcome on every single run, and a warning
/// there would be noise on a path that is working as intended.
pub async fn project_favorites_by_workspace(
client: &reqwest::Client,
configs: &Configs,
workspace_ids: Vec<String>,
) -> HashMap<String, Vec<String>> {
let backboard = configs.get_backboard();

stream::iter(workspace_ids)
.map(|workspace_id| {
let backboard = backboard.clone();
async move {
let vars = queries::project_favorites::Variables {
workspace_id: workspace_id.clone(),
};
let favorites =
post_graphql::<queries::ProjectFavorites, _>(client, backboard, vars)
.await
.map(|data| data.project_favorites)
.unwrap_or_default();
(workspace_id, favorites)
}
})
.buffer_unordered(FAVORITES_CONCURRENCY)
.collect()
.await
}

#[derive(Debug, Clone)]
pub enum Workspace {
External(UserProjectsExternalWorkspaces),
Expand Down Expand Up @@ -89,15 +135,44 @@ impl Workspace {
projects
}

pub fn projects_with_workspace(&self) -> Vec<ProjectWithWorkspace> {
/// The workspace's projects ordered for display: favorites first, in the
/// order `projectFavorites` returned them, then everything else by most
/// recently updated. Each project is paired with whether it is favorited.
///
/// `favorite_ids` may name projects this workspace does not contain (or
/// that were deleted); those simply never match. Passing an empty slice
/// reproduces the pre-favorites ordering exactly.
pub fn projects_ranked(&self, favorite_ids: &[String]) -> Vec<(Project, bool)> {
let rank: HashMap<&str, usize> = favorite_ids
.iter()
.enumerate()
.map(|(i, id)| (id.as_str(), i))
.collect();

// `projects()` is already updated-at descending, and `sort_by_key` is
// stable, so the non-favorites keep that order beneath the favorites.
let mut projects = self.projects();
projects.sort_by_key(|p| rank.get(p.id()).copied().unwrap_or(usize::MAX));

projects
.into_iter()
.map(|project| {
let is_favorite = rank.contains_key(project.id());
(project, is_favorite)
})
.collect()
}

pub fn projects_with_workspace(&self, favorite_ids: &[String]) -> Vec<ProjectWithWorkspace> {
let workspace_info = WorkspaceInfo {
id: self.id().to_string(),
name: self.name().to_string(),
};
self.projects()
self.projects_ranked(favorite_ids)
.into_iter()
.map(|project| ProjectWithWorkspace {
.map(|(project, is_favorite)| ProjectWithWorkspace {
workspace: workspace_info.clone(),
is_favorite,
project,
})
.collect()
Expand Down Expand Up @@ -204,6 +279,11 @@ pub struct WorkspaceInfo {
#[derive(Debug, Clone, Serialize)]
pub struct ProjectWithWorkspace {
pub workspace: WorkspaceInfo,
/// Whether the authenticated user has starred this project. Always `false`
/// for callers the favorites field refuses (workspace- and project-scoped
/// tokens), which is indistinguishable from "nothing starred" by design —
/// see [`project_favorites_by_workspace`].
pub is_favorite: bool,
#[serde(flatten)]
pub project: Project,
}
Expand Down Expand Up @@ -243,3 +323,140 @@ pub fn pick_workspace(workspaces: Vec<Workspace>, requested: Option<String>) ->
let workspace = prompt_select("Select a workspace", workspaces)?;
Ok(workspace)
}

#[cfg(test)]
mod tests {
use super::*;

/// Builds a member workspace whose projects carry the given ids, names and
/// updated-at stamps. Deserialized from JSON rather than constructed
/// field-by-field so the test exercises the same generated types the query
/// produces.
fn workspace_with(projects: &[(&str, &str, &str)]) -> Workspace {
let edges: Vec<serde_json::Value> = projects
.iter()
.map(|(id, name, updated_at)| {
serde_json::json!({
"node": {
"id": id,
"name": name,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": updated_at,
"deletedAt": null,
"environments": { "edges": [] },
"services": { "edges": [] },
}
})
})
.collect();

let raw = serde_json::json!({
"id": "workspace-1",
"name": "Workspace One",
"team": null,
"projects": { "edges": edges },
});

Workspace::Member(
serde_json::from_value(raw).expect("workspace fixture should deserialize"),
)
}

/// The fixture used by most cases: three projects, oldest to newest, so the
/// default (updated-at descending) order is `newest`, `middle`, `oldest`.
fn three_projects() -> Workspace {
workspace_with(&[
("oldest", "oldest-project", "2026-01-01T00:00:00Z"),
("middle", "middle-project", "2026-02-01T00:00:00Z"),
("newest", "newest-project", "2026-03-01T00:00:00Z"),
])
}

fn ranked_ids(workspace: &Workspace, favorites: &[String]) -> Vec<(String, bool)> {
workspace
.projects_ranked(favorites)
.into_iter()
.map(|(project, is_favorite)| (project.id().to_string(), is_favorite))
.collect()
}

#[test]
fn no_favorites_preserves_updated_at_ordering() {
// The pre-favorites behaviour has to survive verbatim: this is what a
// project-token caller, whom the API refuses, keeps seeing.
assert_eq!(
ranked_ids(&three_projects(), &[]),
vec![
("newest".to_string(), false),
("middle".to_string(), false),
("oldest".to_string(), false),
]
);
}

#[test]
fn favorites_sort_above_everything_else() {
assert_eq!(
ranked_ids(&three_projects(), &["oldest".to_string()]),
vec![
("oldest".to_string(), true),
("newest".to_string(), false),
("middle".to_string(), false),
]
);
}

#[test]
fn favorites_keep_api_order_not_updated_at_order() {
// `projectFavorites` returns ids in the order the Favorites row should
// render them, so a favorite updated long ago still outranks a newer
// one that was starred later.
assert_eq!(
ranked_ids(
&three_projects(),
&["oldest".to_string(), "newest".to_string()],
),
vec![
("oldest".to_string(), true),
("newest".to_string(), true),
("middle".to_string(), false),
]
);
}

#[test]
fn unknown_favorite_ids_are_ignored() {
// Favorites are fetched per workspace, but a starred project may have
// been deleted, or the id may simply not belong to this workspace.
assert_eq!(
ranked_ids(
&three_projects(),
&["not-in-this-workspace".to_string(), "middle".to_string()],
),
vec![
("middle".to_string(), true),
("newest".to_string(), false),
("oldest".to_string(), false),
]
);
}

#[test]
fn projects_with_workspace_carries_the_favorite_flag() {
// The `--json` shape is the contract for scripts, so assert the flag
// rides along with the right project rather than just the ordering.
let workspace = three_projects();
let rows = workspace.projects_with_workspace(&["middle".to_string()]);

let flags: Vec<(&str, bool)> = rows
.iter()
.map(|row| (row.project.id(), row.is_favorite))
.collect();

assert_eq!(
flags,
vec![("middle", true), ("newest", false), ("oldest", false)]
);
assert!(rows.iter().all(|row| row.workspace.id == "workspace-1"));
}
}
Loading