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}