mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
Improve workflow list output with grouped sections, descriptions, and tests
Show workflows grouped by User/Project with directory paths in headings, aligned NAME/DESCRIPTION columns, truncated goal snippets, and (none) for empty sections. Add tests for list_workflows_detailed, read_workflow_goal, and truncate_str. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2bc211f267
commit
d207c4b1f7
2 changed files with 281 additions and 9 deletions
|
|
@ -168,6 +168,20 @@ fn user_workflows_dir() -> Option<PathBuf> {
|
|||
dirs::home_dir().map(|h| h.join(".fabro").join("workflows"))
|
||||
}
|
||||
|
||||
/// Metadata about a discovered workflow.
|
||||
pub struct WorkflowInfo {
|
||||
pub name: String,
|
||||
pub goal: Option<String>,
|
||||
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<String> {
|
||||
let Ok(entries) = std::fs::read_dir(workflows_dir) else {
|
||||
|
|
@ -186,6 +200,50 @@ fn list_workflows_in(workflows_dir: &Path) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Read the `goal` field from a `workflow.toml` without full config validation.
|
||||
fn read_workflow_goal(workflow_toml: &Path) -> Option<String> {
|
||||
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<WorkflowInfo> {
|
||||
let mut infos: Vec<WorkflowInfo> = Vec::new();
|
||||
let mut seen: Vec<String> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
" {:<name_width$} {}",
|
||||
styles.bold_dim.apply_to("NAME"),
|
||||
styles.bold_dim.apply_to("DESCRIPTION"),
|
||||
);
|
||||
for w in workflows {
|
||||
let goal_str = w
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(|g| truncate_str(g, GOAL_MAX_LEN))
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
" {:<name_width$} {}",
|
||||
styles.cyan.apply_to(&w.name),
|
||||
styles.dim.apply_to(goal_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue