Skip to main content

sqlite_graphrag/commands/
namespace_detect.rs

1//! Handler for the `namespace-detect` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::namespace;
5use crate::output;
6use serde::Serialize;
7
8#[derive(clap::Args)]
9#[command(after_long_help = "EXAMPLES:\n  \
10    # Resolve namespace using current environment and cwd\n  \
11    sqlite-graphrag namespace-detect\n\n  \
12    # Override with an explicit namespace flag\n  \
13    sqlite-graphrag namespace-detect --namespace my-project\n\n  \
14    # Explicit namespace flag\n  \
15    sqlite-graphrag namespace-detect --namespace ci-runner")]
16/// Namespace detect args.
17pub struct NamespaceDetectArgs {
18    /// Namespace scope.
19    #[arg(long)]
20    pub namespace: Option<String>,
21    /// Explicit database path. Accepted as a no-op to preserve the global contract.
22    #[arg(long)]
23    pub db: Option<String>,
24    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
25    #[arg(long, default_value_t = false)]
26    pub json: bool,
27}
28
29#[derive(Serialize)]
30struct NamespaceDetectResponse {
31    namespace: String,
32    source: namespace::NamespaceSource,
33    cwd: String,
34    /// Total execution time in milliseconds from handler start to serialisation.
35    elapsed_ms: u64,
36}
37
38/// Run.
39pub fn run(args: NamespaceDetectArgs) -> Result<(), AppError> {
40    let inicio = std::time::Instant::now();
41    let _ = args.db;
42    let _ = args.json; // --json is a no-op because output is already JSON by default
43    let resolution = namespace::detect_namespace(args.namespace.as_deref())?;
44    output::emit_json(&NamespaceDetectResponse {
45        namespace: resolution.namespace,
46        source: resolution.source,
47        cwd: resolution.cwd,
48        elapsed_ms: inicio.elapsed().as_millis() as u64,
49    })?;
50    Ok(())
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::namespace::NamespaceSource;
57    use clap::Parser;
58    use serial_test::serial;
59
60    #[test]
61    #[serial]
62    fn namespace_detect_default_returns_global_via_detect() {
63        // Without --namespace and without XDG namespace.default, default is "global".
64        // Product env is not read (G-T-XDG-04).
65        let resolution = namespace::detect_namespace(None).unwrap();
66        // Host may have XDG namespace.default; accept Default or XdgConfig.
67        assert!(!resolution.namespace.is_empty());
68        if resolution.source == NamespaceSource::Default {
69            assert_eq!(resolution.namespace, "global");
70        }
71    }
72
73    #[test]
74    #[serial]
75    fn namespace_detect_explicit_flag_wins() {
76        // GAP-SG-131: product env is not a config channel. Flag always wins.
77        let resolution = namespace::detect_namespace(Some("flag-namespace")).unwrap();
78        assert_eq!(resolution.namespace, "flag-namespace");
79        assert_eq!(resolution.source, NamespaceSource::ExplicitFlag);
80    }
81
82    #[test]
83    #[serial]
84    fn namespace_detect_default_when_no_flag() {
85        // G-T-XDG-04: product env is not read; without --namespace / XDG default,
86        // resolution falls back to "global" (or XDG namespace.default if set on host).
87        let resolution = namespace::detect_namespace(None).unwrap();
88        assert!(!resolution.namespace.is_empty());
89        assert!(
90            matches!(
91                resolution.source,
92                NamespaceSource::Default | NamespaceSource::XdgConfig
93            ),
94            "unexpected source {:?}",
95            resolution.source
96        );
97    }
98
99    #[test]
100    fn namespace_detect_response_serializes_all_fields() {
101        let resp = NamespaceDetectResponse {
102            namespace: "meu-projeto".to_string(),
103            source: NamespaceSource::ExplicitFlag,
104            cwd: "/home/usuario/projeto".to_string(),
105            elapsed_ms: 3,
106        };
107        let json = serde_json::to_value(&resp).unwrap();
108        assert_eq!(json["namespace"], "meu-projeto");
109        assert_eq!(json["source"], "explicit_flag");
110        assert!(json["cwd"].is_string());
111        assert_eq!(json["elapsed_ms"], 3);
112    }
113
114    #[test]
115    fn namespace_source_serializes_in_snake_case() {
116        let cases = vec![
117            (NamespaceSource::ExplicitFlag, "explicit_flag"),
118            (NamespaceSource::XdgConfig, "xdg_config"),
119            (NamespaceSource::Default, "default"),
120        ];
121        for (source, expected) in cases {
122            let json = serde_json::to_value(source).unwrap();
123            assert_eq!(
124                json, expected,
125                "NamespaceSource::{source:?} must serialize as \"{expected}\""
126            );
127        }
128    }
129
130    #[test]
131    fn namespace_detect_accepts_db_as_noop() {
132        let cli = crate::cli::Cli::try_parse_from([
133            "sqlite-graphrag",
134            "namespace-detect",
135            "--db",
136            "/tmp/graphrag.sqlite",
137        ])
138        .expect("namespace-detect must accept --db as a no-op");
139
140        match cli.command {
141            Some(crate::cli::Commands::NamespaceDetect(args)) => {
142                assert_eq!(args.db.as_deref(), Some("/tmp/graphrag.sqlite"));
143            }
144            _ => unreachable!("unexpected command parsed"),
145        }
146    }
147}