Skip to main content

sparrow/
intel_cli.rs

1use crate::cli::IntelAction;
2use crate::config::Config;
3use anyhow::Result;
4
5pub async fn handle_intel_action(action: IntelAction, config: &Config) -> Result<()> {
6    match action {
7        IntelAction::Scan {
8            config: sources_file,
9            source,
10            limit,
11            json,
12        } => {
13            let sources = resolve_sources(config, sources_file.as_deref(), &source)?;
14            if sources.is_empty() {
15                anyhow::bail!(
16                    "No intel sources configured. Set intel.enabled=true with [[intel.sources]] entries, pass --config, or pass --source kind:name:url."
17                );
18            }
19            if !config.intel.enabled && source.is_empty() && sources_file.is_none() {
20                anyhow::bail!(
21                    "intel.enabled=false, so no network scan was started. Use `sparrow intel report` for cached data, or explicitly pass --source/--config."
22                );
23            }
24            let cache = sparrow_intel::default_cache_path(&config.state_dir);
25            let report = sparrow_intel::scan_sources(&sources, &cache, limit).await?;
26            if json {
27                println!("{}", serde_json::to_string_pretty(&report)?);
28            } else {
29                println!(
30                    "Intel scan: {} source(s), {} item(s) cached at {}",
31                    report.scanned,
32                    report.inserted_or_updated,
33                    cache.display()
34                );
35                for item in report.items.iter().take(10) {
36                    println!("- {} {} · {}", item.source, item.version, item.url);
37                }
38            }
39        }
40        IntelAction::Report { limit, json } => {
41            let cache = sparrow_intel::IntelCache::open(sparrow_intel::default_cache_path(
42                &config.state_dir,
43            ))?;
44            let digests = cache.digests(limit)?;
45            if json {
46                println!("{}", serde_json::to_string_pretty(&digests)?);
47            } else if digests.is_empty() {
48                println!(
49                    "No cached intel digests yet. Run `sparrow intel scan` with opt-in sources."
50                );
51            } else {
52                for digest in digests {
53                    println!(
54                        "- {} {} · {}\n  {}\n  {}",
55                        digest.source, digest.version, digest.title, digest.summary, digest.url
56                    );
57                }
58            }
59        }
60        IntelAction::Backlog { limit, json } => {
61            let cache = sparrow_intel::IntelCache::open(sparrow_intel::default_cache_path(
62                &config.state_dir,
63            ))?;
64            let tickets = cache.backlog(limit)?;
65            if json {
66                println!("{}", serde_json::to_string_pretty(&tickets)?);
67            } else if tickets.is_empty() {
68                println!(
69                    "No scored intel backlog yet. Cached digests did not match Sparrow signals."
70                );
71            } else {
72                for ticket in tickets {
73                    println!(
74                        "- [{}] {} · {}\n  {}",
75                        ticket.score, ticket.title, ticket.reason, ticket.url
76                    );
77                }
78            }
79        }
80        IntelAction::Watch {
81            interval,
82            config: sources_file,
83            source,
84        } => {
85            let sources = resolve_sources(config, sources_file.as_deref(), &source)?;
86            if sources.is_empty() {
87                anyhow::bail!("No intel sources configured for watch.");
88            }
89            if !config.intel.enabled && source.is_empty() && sources_file.is_none() {
90                anyhow::bail!(
91                    "intel.enabled=false, so watch will not start without explicit --source/--config."
92                );
93            }
94            let cache = sparrow_intel::default_cache_path(&config.state_dir);
95            loop {
96                let report = sparrow_intel::scan_sources(&sources, &cache, 5).await?;
97                println!(
98                    "Intel watch tick: {} source(s), {} item(s)",
99                    report.scanned, report.inserted_or_updated
100                );
101                tokio::time::sleep(std::time::Duration::from_secs(interval.max(60))).await;
102            }
103        }
104    }
105    Ok(())
106}
107
108fn resolve_sources(
109    config: &Config,
110    sources_file: Option<&std::path::Path>,
111    explicit: &[String],
112) -> Result<Vec<sparrow_intel::SourceConfig>> {
113    let mut sources = Vec::new();
114    if let Some(path) = sources_file {
115        sources.extend(sparrow_intel::load_sources_file(path)?);
116    }
117    for raw in explicit {
118        sources.push(parse_source_arg(raw)?);
119    }
120    if sources.is_empty() {
121        sources.extend(
122            config
123                .intel
124                .sources
125                .iter()
126                .filter_map(|s| s.to_sparrow_intel()),
127        );
128    }
129    Ok(sources)
130}
131
132fn parse_source_arg(raw: &str) -> Result<sparrow_intel::SourceConfig> {
133    let mut parts = raw.splitn(3, ':');
134    let kind_raw = parts.next().unwrap_or_default();
135    let name = parts.next().unwrap_or_default();
136    let url = parts.next().unwrap_or_default();
137    if kind_raw.is_empty() || name.is_empty() || url.is_empty() {
138        anyhow::bail!("--source must use kind:name:url");
139    }
140    let kind = match kind_raw {
141        "github_releases" => sparrow_intel::SourceKind::GithubReleases,
142        "changelog_url" => sparrow_intel::SourceKind::ChangelogUrl,
143        "docs_url" => sparrow_intel::SourceKind::DocsUrl,
144        _ => anyhow::bail!("unknown intel source kind: {kind_raw}"),
145    };
146    Ok(sparrow_intel::SourceConfig {
147        name: name.to_string(),
148        kind,
149        url: url.to_string(),
150        tags: Vec::new(),
151    })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn explicit_source_arg_parses() {
160        let src = parse_source_arg("github_releases:Codex:https://github.com/openai/codex")
161            .expect("source arg should parse");
162        assert_eq!(src.name, "Codex");
163        assert_eq!(src.kind, sparrow_intel::SourceKind::GithubReleases);
164    }
165
166    #[tokio::test]
167    async fn disabled_intel_without_explicit_source_refuses_scan_before_network() {
168        let mut cfg = Config::default();
169        cfg.intel.enabled = false;
170        cfg.intel.sources = vec![crate::config::IntelSourceConfig {
171            name: "example".into(),
172            kind: "github_releases".into(),
173            url: "https://github.com/openai/codex".into(),
174            tags: Vec::new(),
175        }];
176        let result = handle_intel_action(
177            IntelAction::Scan {
178                config: None,
179                source: Vec::new(),
180                limit: 1,
181                json: true,
182            },
183            &cfg,
184        )
185        .await;
186        assert!(result.is_err());
187        assert!(
188            result
189                .unwrap_err()
190                .to_string()
191                .contains("intel.enabled=false")
192        );
193    }
194}