sem-cli 0.6.2

Semantic version control CLI. Shows what entities changed (functions, classes, methods) instead of lines.
use std::path::Path;

use colored::Colorize;
use sem_core::git::bridge::GitBridge;
use sem_core::parser::context::build_context_result;
use sem_core::parser::graph::EntityGraph;

pub struct ContextOptions {
    pub cwd: String,
    pub entity_name: Option<String>,
    pub entity_id: Option<String>,
    pub file_path: Option<String>,
    pub budget: usize,
    pub json: bool,
    pub file_exts: Vec<String>,
    pub no_cache: bool,
}

pub fn context_command(opts: ContextOptions) {
    let root = match GitBridge::open(Path::new(&opts.cwd)) {
        Ok(git) => git.repo_root().to_path_buf(),
        Err(_) => Path::new(&opts.cwd).to_path_buf(),
    };
    let root = root.as_path();
    let registry = super::create_registry(&root.to_string_lossy());
    let ext_filter = super::graph::normalize_exts(&opts.file_exts);

    let file_paths = super::graph::find_supported_files_public(root, &registry, &ext_filter);
    let (graph, all_entities) = super::graph::get_or_build_graph(root, &file_paths, &registry, opts.no_cache);

    let entity = find_entity(&graph, opts.entity_name.as_deref(), opts.entity_id.as_deref(), opts.file_path.as_deref());
    let context_result = build_context_result(&graph, &entity.id, &all_entities, opts.budget);

    if opts.json {
        let output = serde_json::json!({
            "entity": entity.name,
            "entityId": entity.id,
            "budget": opts.budget,
            "total_tokens": context_result.total_tokens,
            "truncated": context_result.truncated,
            "target_omitted": context_result.target_omitted,
            "entries": context_result.entries.iter().map(|e| serde_json::json!({
                "entityId": e.entity_id,
                "name": e.entity_name,
                "type": e.entity_type,
                "file": e.file_path,
                "role": e.role,
                "tokens": e.estimated_tokens,
                "content": e.content,
            })).collect::<Vec<_>>(),
        });
        println!("{}", serde_json::to_string(&output).unwrap());
    } else {
        println!(
            "{} {} {} (budget: {}, used: {})\n",
            "context for".green().bold(),
            entity.entity_type.dimmed(),
            entity.name.bold(),
            opts.budget,
            context_result.total_tokens,
        );

        if context_result.target_omitted {
            println!("  {}", "target omitted: signature exceeds token budget".dimmed());
        }

        let mut current_role = String::new();
        for entry in &context_result.entries {
            if entry.role != current_role {
                current_role = entry.role.clone();
                let role_label = match current_role.as_str() {
                    "target" => "target".green().bold(),
                    "direct_dependency" => "direct dependencies".cyan().bold(),
                    "direct_dependent" => "direct dependents".yellow().bold(),
                    "transitive_dependency" => "transitive dependencies".blue().bold(),
                    "transitive_dependent" => "transitive dependents".dimmed().bold(),
                    _ => current_role.normal().bold(),
                };
                println!("  {}:", role_label);
            }

            let snippet: String = entry.content.lines().next().unwrap_or("").to_string();
            println!(
                "    {} {} ({}, ~{} tokens)",
                entry.entity_type.dimmed(),
                entry.entity_name.bold(),
                entry.file_path.dimmed(),
                entry.estimated_tokens,
            );
            if !snippet.is_empty() {
                println!("      {}", snippet.dimmed());
            }
        }
    }
}

fn find_entity<'a>(
    graph: &'a EntityGraph,
    name: Option<&str>,
    entity_id: Option<&str>,
    file_hint: Option<&str>,
) -> &'a sem_core::parser::graph::EntityInfo {
    // Direct lookup by entity ID
    if let Some(id) = entity_id {
        if let Some(e) = graph.entities.get(id) {
            return e;
        }
        eprintln!("{} Entity ID '{}' not found", "error:".red().bold(), id);
        std::process::exit(1);
    }

    let name = name.unwrap_or_else(|| {
        eprintln!("{} Either entity name or --entity-id is required", "error:".red().bold());
        std::process::exit(1);
    });

    let mut matching: Vec<_> = graph.entities.values().filter(|e| e.name == name).collect();

    if matching.is_empty() {
        eprintln!("{} Entity '{}' not found", "error:".red().bold(), name);
        std::process::exit(1);
    }

    if let Some(file) = file_hint {
        let filtered: Vec<_> = matching.iter().filter(|e| e.file_path == file).copied().collect();
        if filtered.len() == 1 {
            return filtered[0];
        }
        if filtered.is_empty() {
            eprintln!("{} Entity '{}' not found in file '{}'", "error:".red().bold(), name, file);
            std::process::exit(1);
        }
        matching = filtered;
    }

    if matching.len() == 1 {
        return matching[0];
    }

    matching.sort_by_key(|e| (&e.file_path, e.start_line));
    eprintln!("{} Entity name '{}' is ambiguous ({} matches). Specify --file or --entity-id:", "error:".red().bold(), name, matching.len());
    for m in &matching {
        eprintln!("  {} ({}:L{})", m.id, m.file_path, m.start_line);
    }
    std::process::exit(1);
}