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