loopflow 0.12.8

Run steps and flows with coding agents
Documentation
use crate::lf::commands::util::find_repo_root;
use crate::lf::discovery::{
    builtin_flow_infos, builtin_flows, builtin_skill_description, builtin_skills, list_all_skills,
    list_user_flows, FlowInfo, BUILTIN_FLOW_CATEGORIES, BUILTIN_SKILL_CATEGORIES,
};
use crate::lf::output::Colors;
use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
use std::path::Path;

const NAME_WIDTH: usize = 26;
const DESC_WIDTH: usize = 48;

#[derive(Debug, Default)]
struct CatalogEntry {
    flow: Option<FlowInfo>,
    skill_description: Option<String>,
    customized: bool,
}

pub fn show_all() -> Result<()> {
    let repo_root = find_repo_root().ok();
    print!("{}", render_all(repo_root.as_deref(), Colors::default()));
    Ok(())
}

fn render_all(repo_root: Option<&Path>, colors: Colors) -> String {
    let (user_skills, global_skills, _builtin_only, external_skills) = list_all_skills(repo_root);
    let user_skill_set: HashSet<_> = user_skills.iter().cloned().collect();
    let builtin_skill_set = builtin_skills();
    let builtin_flow_set = builtin_flows();
    let flow_infos = builtin_flow_infos(repo_root);
    let user_flows = repo_root.map(list_user_flows).unwrap_or_default();
    let user_flow_by_name: HashMap<_, _> = user_flows
        .iter()
        .map(|flow| (flow.name.clone(), flow.clone()))
        .collect();

    let mut output = String::new();
    writeln!(
        output,
        "{cyan}{bold}CATALOG{reset}\n",
        cyan = colors.cyan,
        bold = colors.bold,
        reset = colors.reset
    )
    .expect("write catalog heading");

    for category in builtin_category_order() {
        let mut entries = BTreeMap::new();

        for name in category_members(BUILTIN_FLOW_CATEGORIES, &category) {
            let custom_flow = user_flow_by_name.get(*name);
            let entry = entries
                .entry((*name).to_string())
                .or_insert_with(CatalogEntry::default);
            entry.flow = custom_flow
                .cloned()
                .or_else(|| flow_infos.get(*name).cloned());
            entry.customized = custom_flow.is_some();
        }

        for name in category_members(BUILTIN_SKILL_CATEGORIES, &category) {
            let entry = entries
                .entry((*name).to_string())
                .or_insert_with(CatalogEntry::default);
            entry.skill_description = Some(builtin_skill_description(name));
            entry.customized |= user_skill_set.contains(*name);
        }

        if !entries.is_empty() {
            render_category(&mut output, &category, &entries, colors);
        }
    }

    let mut user_entries = BTreeMap::new();
    for flow in user_flows
        .iter()
        .filter(|flow| !builtin_flow_set.contains(&flow.name))
    {
        user_entries
            .entry(flow.name.clone())
            .or_insert_with(CatalogEntry::default)
            .flow = Some(flow.clone());
    }
    for name in user_skills
        .iter()
        .filter(|name| !builtin_skill_set.contains(*name))
    {
        let entry = user_entries
            .entry(name.clone())
            .or_insert_with(CatalogEntry::default);
        entry.skill_description = Some(String::new());
    }
    if !user_entries.is_empty() {
        render_category(&mut output, "User", &user_entries, colors);
    }

    if !global_skills.is_empty() {
        let entries = skill_only_entries(&global_skills);
        render_category(&mut output, "Global", &entries, colors);
    }

    let mut external_by_source: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (name, source) in external_skills {
        external_by_source.entry(source).or_default().push(name);
    }
    for (source, mut skills) in external_by_source {
        skills.sort();
        let entries = skill_only_entries(&skills);
        render_category(&mut output, &source, &entries, colors);
    }

    writeln!(
        output,
        "{dim}Run lf <name>, lf flow <name>, or lf skill <name> [args]{reset}",
        dim = colors.dim,
        reset = colors.reset
    )
    .expect("write catalog footer");
    output
}

fn builtin_category_order() -> Vec<String> {
    let mut categories: BTreeSet<String> = BUILTIN_FLOW_CATEGORIES
        .iter()
        .chain(BUILTIN_SKILL_CATEGORIES.iter())
        .map(|(category, _)| (*category).to_string())
        .collect();
    let mut ordered = Vec::new();
    for core in ["Task", "Project", "Wave", "Ops"] {
        if categories.remove(core) {
            ordered.push(core.to_string());
        }
    }
    ordered.extend(categories);
    ordered
}

fn category_members<'a>(categories: &'a [(&str, &[&str])], category: &str) -> &'a [&'a str] {
    categories
        .iter()
        .find_map(|(name, members)| (*name == category).then_some(*members))
        .unwrap_or(&[])
}

fn skill_only_entries(skills: &[String]) -> BTreeMap<String, CatalogEntry> {
    skills
        .iter()
        .map(|name| {
            (
                name.clone(),
                CatalogEntry {
                    skill_description: Some(String::new()),
                    ..CatalogEntry::default()
                },
            )
        })
        .collect()
}

fn render_category(
    output: &mut String,
    category: &str,
    entries: &BTreeMap<String, CatalogEntry>,
    colors: Colors,
) {
    writeln!(
        output,
        "{dim}{category}{reset}",
        dim = colors.dim,
        reset = colors.reset
    )
    .expect("write catalog category");

    for (name, entry) in entries {
        let kind = match (entry.flow.is_some(), entry.skill_description.is_some()) {
            (true, true) => "flow+skill",
            (true, false) => "flow",
            (false, true) => "skill",
            (false, false) => continue,
        };
        let description = entry
            .skill_description
            .as_deref()
            .map(|description| truncate(description, DESC_WIDTH))
            .unwrap_or_default();
        let detail = if description.is_empty() {
            kind.to_string()
        } else {
            format!("{kind:<10} {description}")
        };
        let customized = if entry.customized {
            format!(
                "  {dim}customized{reset}",
                dim = colors.dim,
                reset = colors.reset
            )
        } else {
            String::new()
        };

        writeln!(
            output,
            "  {bold}{name:<NAME_WIDTH$}{reset} {dim}{detail}{reset}{customized}",
            bold = colors.bold,
            reset = colors.reset,
            dim = colors.dim,
        )
        .expect("write catalog entry");

        if let Some(flow) = &entry.flow {
            if flow.written == flow.collapsed {
                writeln!(
                    output,
                    "    {dim}{value}{reset}",
                    dim = colors.dim,
                    value = flow.written,
                    reset = colors.reset
                )
                .expect("write unchanged flow");
            } else {
                writeln!(
                    output,
                    "    {dim}{label:<10}{value}{reset}",
                    dim = colors.dim,
                    label = "written",
                    value = flow.written,
                    reset = colors.reset
                )
                .expect("write authored flow");
                writeln!(
                    output,
                    "    {dim}{label:<10}{value}{reset}",
                    dim = colors.dim,
                    label = "collapsed",
                    value = flow.collapsed,
                    reset = colors.reset
                )
                .expect("write collapsed flow");
            }
        }
    }
    output.push('\n');
}

fn truncate(value: &str, width: usize) -> String {
    if value.chars().count() <= width {
        return value.to_string();
    }
    let truncated: String = value.chars().take(width.saturating_sub(1)).collect();
    format!("{truncated}…")
}

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

    const NO_COLORS: Colors = Colors {
        cyan: "",
        bold: "",
        dim: "",
        yellow: "",
        green: "",
        red: "",
        reset: "",
    };

    #[test]
    fn unified_catalog_shows_written_and_collapsed_flows() {
        let rendered = render_all(None, NO_COLORS);

        assert!(rendered.starts_with("CATALOG\n\nTask\n"));
        assert!(!rendered.contains("\nFLOWS\n"));
        assert!(!rendered.contains("\nSKILLS\n"));
        assert!(rendered.contains("build                      flow"));
        assert!(rendered.contains("written   kickoff → code → review-slice → demo"));
        assert!(rendered.contains("collapsed kickoff → implement → compress → review-slice → demo"));
        assert!(rendered.contains("code                       flow\n    implement → compress\n"));
        assert!(!rendered
            .lines()
            .any(|line| line == "    written   implement → compress"));
        assert!(!rendered
            .lines()
            .any(|line| line == "    collapsed implement → compress"));
        assert!(rendered.contains("wave/clarify"));
        assert!(!rendered.contains("wave_clarify"));
        assert!(rendered.lines().all(|line| line.trim_end() == line));
    }
}