llman 0.0.51

A tool for managing LLM application rules(prompts) ...
Documentation
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::shared::discovery::{extract_archived_change_id, list_changes};
use crate::sdd::shared::tasks::{self, TaskStatus};
use anyhow::Result;
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct OrphansArgs {
    pub json: bool,
    pub compact_json: bool,
}

struct OrphanItem {
    text: String,
    legacy: bool,
}

pub fn run(args: OrphansArgs) -> Result<()> {
    let root = Path::new(".");
    run_with_root(root, args)
}

fn run_with_root(root: &Path, args: OrphansArgs) -> Result<()> {
    let active_ids: HashSet<String> = list_changes(root)?.into_iter().collect();
    let archived_ids = collect_archived_ids(root);
    let all_known: HashSet<&str> = active_ids
        .iter()
        .chain(archived_ids.iter())
        .map(|s| s.as_str())
        .collect();

    let mut orphans: BTreeMap<String, Vec<OrphanItem>> = BTreeMap::new();

    scan_dir_for_orphans(
        &root.join(LLMANSPEC_DIR_NAME).join("changes"),
        &all_known,
        &mut orphans,
        false,
    );
    scan_dir_for_orphans(
        &root
            .join(LLMANSPEC_DIR_NAME)
            .join("changes")
            .join("archive"),
        &all_known,
        &mut orphans,
        true,
    );

    let total: usize = orphans.values().map(|v| v.len()).sum();

    if args.json {
        print_json(&orphans, total, args.compact_json)?;
    } else {
        print_text(&orphans, total);
    }

    Ok(())
}

fn scan_dir_for_orphans(
    dir: &Path,
    all_known: &HashSet<&str>,
    orphans: &mut BTreeMap<String, Vec<OrphanItem>>,
    is_archive: bool,
) {
    let entries = match fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return,
    };

    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let dir_name = entry.file_name().to_string_lossy().to_string();
        if dir_name.starts_with('.') || (!is_archive && dir_name == "archive") {
            continue;
        }

        let change_id = if is_archive {
            match extract_archived_change_id(&dir_name) {
                Some(id) => id,
                None => continue,
            }
        } else {
            dir_name.clone()
        };

        let tasks_path = entry.path().join("tasks.md");
        let report = match tasks::parse_tasks_file(&tasks_path) {
            Ok(Some(r)) => r,
            _ => continue,
        };

        for item in &report.items {
            match &item.status {
                TaskStatus::Deferred { target } if !all_known.contains(target.as_str()) => {
                    orphans
                        .entry(change_id.clone())
                        .or_default()
                        .push(OrphanItem {
                            text: item.text.clone(),
                            legacy: false,
                        });
                }
                TaskStatus::LegacyDefer { .. } => {
                    orphans
                        .entry(change_id.clone())
                        .or_default()
                        .push(OrphanItem {
                            text: item.text.clone(),
                            legacy: true,
                        });
                }
                _ => {}
            }
        }
    }
}

fn collect_archived_ids(root: &Path) -> HashSet<String> {
    let archive_dir = root
        .join(LLMANSPEC_DIR_NAME)
        .join("changes")
        .join("archive");
    let entries = match fs::read_dir(archive_dir) {
        Ok(e) => e,
        Err(_) => return HashSet::new(),
    };
    let mut ids = HashSet::new();
    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let name = entry.file_name().to_string_lossy().to_string();
        if let Some(id) = extract_archived_change_id(&name) {
            ids.insert(id);
        }
    }
    ids
}

fn print_text(orphans: &BTreeMap<String, Vec<OrphanItem>>, total: usize) {
    if total == 0 {
        println!("{}", t!("sdd.orphans.no_orphans"));
        return;
    }

    println!(
        "{}",
        t!("sdd.orphans.header", count = total, changes = orphans.len())
    );
    println!();

    for (change_id, items) in orphans {
        println!("{}", t!("sdd.orphans.change_header", change = change_id));
        for item in items {
            if item.legacy {
                println!("{}", t!("sdd.orphans.legacy_item", task = item.text));
            } else {
                println!("{}", t!("sdd.orphans.item", task = item.text));
            }
        }
        println!();
    }

    println!("{}", t!("sdd.orphans.suggestion"));
}

fn print_json(
    orphans: &BTreeMap<String, Vec<OrphanItem>>,
    total: usize,
    compact: bool,
) -> Result<()> {
    let items: serde_json::Value = orphans
        .iter()
        .map(|(change_id, items)| {
            let tasks: Vec<serde_json::Value> = items
                .iter()
                .map(|item| {
                    serde_json::json!({
                        "text": item.text,
                        "legacy": item.legacy,
                    })
                })
                .collect();
            (change_id.clone(), serde_json::Value::Array(tasks))
        })
        .collect::<serde_json::Map<String, serde_json::Value>>()
        .into();

    let output = serde_json::json!({
        "totalOrphans": total,
        "changes": orphans.len(),
        "items": items,
    });

    if compact {
        println!("{}", serde_json::to_string(&output)?);
    } else {
        println!("{}", serde_json::to_string_pretty(&output)?);
    }

    Ok(())
}