diff --git a/lib/crates/fabro-workflows/src/cli/project_config.rs b/lib/crates/fabro-workflows/src/cli/project_config.rs index b7478e63f..ce0d91240 100644 --- a/lib/crates/fabro-workflows/src/cli/project_config.rs +++ b/lib/crates/fabro-workflows/src/cli/project_config.rs @@ -168,6 +168,20 @@ fn user_workflows_dir() -> Option { dirs::home_dir().map(|h| h.join(".fabro").join("workflows")) } +/// Metadata about a discovered workflow. +pub struct WorkflowInfo { + pub name: String, + pub goal: Option, + pub source: WorkflowSource, +} + +/// Where a workflow was discovered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorkflowSource { + Project, + User, +} + /// List workflow names in a single directory by scanning for subdirs containing `workflow.toml`. fn list_workflows_in(workflows_dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(workflows_dir) else { @@ -186,6 +200,50 @@ fn list_workflows_in(workflows_dir: &Path) -> Vec { .collect() } +/// Read the `goal` field from a `workflow.toml` without full config validation. +fn read_workflow_goal(workflow_toml: &Path) -> Option { + let content = std::fs::read_to_string(workflow_toml).ok()?; + let table: toml::Table = content.parse().ok()?; + table.get("goal")?.as_str().map(String::from) +} + +/// List workflows with metadata by scanning project and user workflow directories. +pub fn list_workflows_detailed( + project_workflows_dir: Option<&Path>, + user_workflows_dir: Option<&Path>, +) -> Vec { + let mut infos: Vec = Vec::new(); + let mut seen: Vec = Vec::new(); + + if let Some(dir) = project_workflows_dir { + for name in list_workflows_in(dir) { + let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml")); + seen.push(name.clone()); + infos.push(WorkflowInfo { + name, + goal, + source: WorkflowSource::Project, + }); + } + } + if let Some(dir) = user_workflows_dir { + for name in list_workflows_in(dir) { + if !seen.contains(&name) { + let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml")); + seen.push(name.clone()); + infos.push(WorkflowInfo { + name, + goal, + source: WorkflowSource::User, + }); + } + } + } + + infos.sort_by(|a, b| a.name.cmp(&b.name)); + infos +} + /// List workflow names by scanning project and user workflow directories. /// Project workflows appear first; user workflows are deduplicated. pub fn list_available_workflows( @@ -633,4 +691,114 @@ mod tests { assert!(msg.contains("proj-wf"), "expected proj-wf in: {msg}"); assert!(msg.contains("user-wf"), "expected user-wf in: {msg}"); } + + fn create_workflow_with_goal(base: &Path, name: &str, goal: &str) { + let wf_dir = base.join("workflows").join(name); + fs::create_dir_all(&wf_dir).unwrap(); + fs::write( + wf_dir.join("workflow.toml"), + format!("version = 1\ngoal = \"{goal}\"\ngraph = \"workflow.fabro\"\n"), + ) + .unwrap(); + } + + #[test] + fn list_workflows_detailed_project_only() { + let tmp = TempDir::new().unwrap(); + create_workflow_in(tmp.path(), "alpha"); + create_workflow_with_goal(tmp.path(), "beta", "Run tests"); + + let wf_dir = tmp.path().join("workflows"); + let infos = list_workflows_detailed(Some(&wf_dir), None); + + assert_eq!(infos.len(), 2); + assert_eq!(infos[0].name, "alpha"); + assert_eq!(infos[0].goal, None); + assert_eq!(infos[0].source, WorkflowSource::Project); + assert_eq!(infos[1].name, "beta"); + assert_eq!(infos[1].goal.as_deref(), Some("Run tests")); + assert_eq!(infos[1].source, WorkflowSource::Project); + } + + #[test] + fn list_workflows_detailed_user_only() { + let user = TempDir::new().unwrap(); + create_workflow_with_goal(user.path(), "my-wf", "Deploy app"); + + let user_wf_dir = user.path().join("workflows"); + let infos = list_workflows_detailed(None, Some(&user_wf_dir)); + + assert_eq!(infos.len(), 1); + assert_eq!(infos[0].name, "my-wf"); + assert_eq!(infos[0].goal.as_deref(), Some("Deploy app")); + assert_eq!(infos[0].source, WorkflowSource::User); + } + + #[test] + fn list_workflows_detailed_deduplicates_user() { + let project = TempDir::new().unwrap(); + create_workflow_with_goal(project.path(), "shared", "Project version"); + + let user = TempDir::new().unwrap(); + create_workflow_with_goal(user.path(), "shared", "User version"); + create_workflow_in(user.path(), "user-only"); + + let project_wf_dir = project.path().join("workflows"); + let user_wf_dir = user.path().join("workflows"); + let infos = list_workflows_detailed(Some(&project_wf_dir), Some(&user_wf_dir)); + + assert_eq!(infos.len(), 2); + let shared = infos.iter().find(|w| w.name == "shared").unwrap(); + assert_eq!(shared.source, WorkflowSource::Project); + assert_eq!(shared.goal.as_deref(), Some("Project version")); + let user_only = infos.iter().find(|w| w.name == "user-only").unwrap(); + assert_eq!(user_only.source, WorkflowSource::User); + } + + #[test] + fn list_workflows_detailed_sorted() { + let tmp = TempDir::new().unwrap(); + create_workflow_in(tmp.path(), "zebra"); + create_workflow_in(tmp.path(), "alpha"); + create_workflow_in(tmp.path(), "middle"); + + let wf_dir = tmp.path().join("workflows"); + let infos = list_workflows_detailed(Some(&wf_dir), None); + let names: Vec<_> = infos.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "middle", "zebra"]); + } + + #[test] + fn list_workflows_detailed_empty_dirs() { + let infos = list_workflows_detailed(None, None); + assert!(infos.is_empty()); + } + + #[test] + fn read_workflow_goal_extracts_goal() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("workflow.toml"); + fs::write( + &path, + "version = 1\ngoal = \"Hello world\"\ngraph = \"w.fabro\"\n", + ) + .unwrap(); + assert_eq!(read_workflow_goal(&path).as_deref(), Some("Hello world")); + } + + #[test] + fn read_workflow_goal_missing_field() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("workflow.toml"); + fs::write(&path, "version = 1\ngraph = \"w.fabro\"\n").unwrap(); + assert_eq!(read_workflow_goal(&path), None); + } + + #[test] + fn read_workflow_goal_missing_file() { + assert_eq!( + read_workflow_goal(Path::new("/nonexistent/workflow.toml")), + None + ); + } } diff --git a/lib/crates/fabro-workflows/src/cli/workflow.rs b/lib/crates/fabro-workflows/src/cli/workflow.rs index 13ae42c95..4b5069ae2 100644 --- a/lib/crates/fabro-workflows/src/cli/workflow.rs +++ b/lib/crates/fabro-workflows/src/cli/workflow.rs @@ -1,14 +1,20 @@ use anyhow::bail; use clap::Args; +use fabro_util::terminal::Styles; use super::project_config::{ - discover_project_config, list_available_workflows, resolve_fabro_root, + discover_project_config, list_workflows_detailed, resolve_fabro_root, WorkflowInfo, + WorkflowSource, }; +use super::relative_path; + +const GOAL_MAX_LEN: usize = 60; #[derive(Args)] pub struct WorkflowListArgs {} pub fn workflow_list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> { + let styles = Styles::detect_stderr(); let cwd = std::env::current_dir()?; let (config_path, config) = match discover_project_config(&cwd)? { @@ -23,15 +29,113 @@ pub fn workflow_list_command(_args: &WorkflowListArgs) -> anyhow::Result<()> { let project_wf_dir = fabro_root.join("workflows"); let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows")); - let workflows = list_available_workflows(Some(&project_wf_dir), user_wf_dir.as_deref()); + let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref()); - if workflows.is_empty() { - eprintln!("No workflows found"); - } else { - for name in &workflows { - println!("{name}"); - } - } + let project: Vec<_> = workflows + .iter() + .filter(|w| w.source == WorkflowSource::Project) + .collect(); + let user: Vec<_> = workflows + .iter() + .filter(|w| w.source == WorkflowSource::User) + .collect(); + + let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0); + + eprintln!( + "{} workflow(s) found\n", + styles.bold.apply_to(workflows.len()) + ); + + let user_path = user_wf_dir + .as_deref() + .map(relative_path) + .unwrap_or_else(|| "~/.fabro/workflows".to_string()); + print_section("User Workflows", &user_path, &user, name_width, &styles); + + eprintln!(); + + print_section( + "Project Workflows", + &relative_path(&project_wf_dir), + &project, + name_width, + &styles, + ); Ok(()) } + +fn print_section( + title: &str, + path: &str, + workflows: &[&WorkflowInfo], + name_width: usize, + styles: &Styles, +) { + eprintln!( + "{} {}", + styles.bold.apply_to(title), + styles.dim.apply_to(format!("({path})")), + ); + if workflows.is_empty() { + eprintln!(" {}", styles.dim.apply_to("(none)")); + return; + } + eprintln!(); + eprintln!( + " {: String { + let first_line = s.lines().next().unwrap_or(s); + if first_line.len() <= max { + first_line.to_string() + } else { + format!("{}...", &first_line[..max - 3]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_str_short() { + assert_eq!(truncate_str("hello", 60), "hello"); + } + + #[test] + fn truncate_str_exact_limit() { + let s = "a".repeat(60); + assert_eq!(truncate_str(&s, 60), s); + } + + #[test] + fn truncate_str_over_limit() { + let s = "a".repeat(70); + let result = truncate_str(&s, 60); + assert_eq!(result.len(), 60); + assert!(result.ends_with("...")); + } + + #[test] + fn truncate_str_multiline_uses_first_line() { + assert_eq!(truncate_str("first\nsecond\nthird", 60), "first"); + } +}