Skip to main content

grex_cli/cli/verbs/
import.rs

1//! `grex import` — ingest a legacy `REPOS.json`.
2
3use crate::cli::args::{GlobalFlags, ImportArgs};
4use anyhow::{anyhow, Context, Result};
5use grex_core::import::{import_from_repos_json, ImportOpts, ImportPlan, SkipReason};
6use tokio_util::sync::CancellationToken;
7
8pub fn run(args: ImportArgs, global: &GlobalFlags, _cancel: &CancellationToken) -> Result<()> {
9    let from = args
10        .from_repos_json
11        .as_deref()
12        .ok_or_else(|| anyhow!("--from-repos-json <path> is required"))?;
13
14    let manifest = args
15        .manifest
16        .clone()
17        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("grex.jsonl"));
18
19    let dry_run = args.dry_run || global.dry_run;
20
21    let plan = import_from_repos_json(from, &manifest, ImportOpts { dry_run })
22        .context("grex import failed")?;
23
24    if global.json {
25        emit_json(&plan, dry_run)?;
26    } else {
27        emit_human(&plan, dry_run);
28    }
29    Ok(())
30}
31
32fn emit_human(plan: &ImportPlan, dry_run: bool) {
33    for entry in &plan.imported {
34        let prefix = if dry_run { "DRY-RUN: would add" } else { "added" };
35        println!(
36            "{prefix} {path:<32} {kind:<12} {url}",
37            path = entry.path,
38            kind = entry.kind.as_str(),
39            url = if entry.url.is_empty() { "-" } else { &entry.url },
40        );
41    }
42    for skip in &plan.skipped {
43        let reason = match skip.reason {
44            SkipReason::PathCollision => "path-collision",
45            SkipReason::DuplicateInInput => "duplicate-in-input",
46        };
47        eprintln!("skip {:<32} {}", skip.path, reason);
48    }
49    println!(
50        "\nsummary: imported={} skipped={} failed={}",
51        plan.imported.len(),
52        plan.skipped.len(),
53        plan.failed.len(),
54    );
55}
56
57/// Canonical `import` JSON shape. Must remain byte-equal to the MCP
58/// handler's output (`crates/grex-mcp/src/tools/import.rs::render_plan_json`)
59/// and match `man/reference/cli-json.md §import`. Any field rename or
60/// addition MUST land in all three places in the same commit.
61///
62/// Shape: `{dry_run, imported[], skipped[], failed[]}`. No `summary`
63/// wrapper — readers derive counts from the arrays directly.
64fn emit_json(plan: &ImportPlan, dry_run: bool) -> Result<()> {
65    let imported: Vec<_> = plan
66        .imported
67        .iter()
68        .map(|e| {
69            serde_json::json!({
70                "path": e.path,
71                "url": e.url,
72                "kind": e.kind.as_str(),
73                "would_dispatch": e.would_dispatch,
74            })
75        })
76        .collect();
77    let skipped: Vec<_> = plan
78        .skipped
79        .iter()
80        .map(|s| {
81            serde_json::json!({
82                "path": s.path,
83                "reason": match s.reason {
84                    SkipReason::PathCollision => "path_collision",
85                    SkipReason::DuplicateInInput => "duplicate_in_input",
86                },
87            })
88        })
89        .collect();
90    let failed: Vec<_> = plan
91        .failed
92        .iter()
93        .map(|f| {
94            serde_json::json!({
95                "path": f.path,
96                "error": f.error,
97            })
98        })
99        .collect();
100    let out = serde_json::json!({
101        "dry_run": dry_run,
102        "imported": imported,
103        "skipped": skipped,
104        "failed": failed,
105    });
106    // Compact form so byte-comparison against the MCP surface (which
107    // uses `Value::to_string`, also compact) is trivial.
108    println!("{}", serde_json::to_string(&out)?);
109    Ok(())
110}