1use chrono::{DateTime, Utc};
5use std::fmt::Write as _;
6
7use crate::db::Watch;
8
9pub fn due_watches(watches: &[Watch], now: DateTime<Utc>) -> Vec<Watch> {
12 watches
13 .iter()
14 .filter(|w| match &w.last_run_at {
15 None => true,
16 Some(t) => DateTime::parse_from_rfc3339(t).map_or(true, |last| {
17 now.signed_duration_since(last) >= chrono::Duration::hours(w.interval_hours)
18 }),
19 })
20 .cloned()
21 .collect()
22}
23
24pub fn diff_section(previous_report: &str, new_report: &str, new_sources: &[String]) -> String {
30 let _ = (previous_report, new_report); let mut out = String::from("## What changed since last run\n\n");
32 if new_sources.is_empty() {
33 out.push_str("No new sources since the last run.\n");
34 } else {
35 out.push_str("New sources:\n");
36 for s in new_sources {
37 let _ = writeln!(out, "- {s}");
38 }
39 }
40 out
41}
42
43pub fn new_sources_since(new_report: &str, previous_citations: &[String]) -> Vec<String> {
46 let previous: std::collections::HashSet<String> = previous_citations
47 .iter()
48 .map(|u| crate::tools::normalize_url(u))
49 .collect();
50 crate::citations::parse_citations(new_report)
51 .into_iter()
52 .map(|(_, url)| url)
53 .filter(|url| !previous.contains(&crate::tools::normalize_url(url)))
54 .collect()
55}
56
57impl super::App {
58 pub fn create_watch(&mut self, topic: &str) {
62 if topic.is_empty() {
63 self.push_status("usage: /watch <topic>".to_string());
64 return;
65 }
66 self.start_research_with_gate(topic, false);
67 let Some(session) = &self.session else {
68 self.push_status("could not start watch: no session created".to_string());
69 return;
70 };
71 match self
72 .db
73 .create_watch(&self.active_space.id, topic, 24, &session.id)
74 {
75 Ok(_) => self.push_status(format!("watching: {topic} (every 24h)")),
76 Err(e) => self.push_status(format!("watch creation failed: {e}")),
77 }
78 }
79
80 pub fn delete_watch(&mut self, id: &str) -> anyhow::Result<bool> {
84 let existed = self.watches_cache.iter().any(|w| w.id == id);
85 if existed {
86 let _ = self.db.delete_watch(id);
87 self.watches_cache.retain(|x| x.id != id);
88 }
89 Ok(existed)
90 }
91
92 pub fn run_due_watches(&mut self) {
97 let Ok(all) = self.db.list_all_watches() else {
98 return;
99 };
100 let due = due_watches(&all, chrono::Utc::now());
101 for w in due {
102 let _ = self.run_one_watch(&w);
103 }
104 }
105
106 pub fn run_one_watch(&mut self, w: &crate::db::Watch) -> bool {
113 let Ok(spaces) = self.db.list_spaces() else {
114 return false;
115 };
116 let Some(space_row) = spaces.into_iter().find(|s| s.id == w.space_id) else {
117 return false;
118 };
119 let restore_space = self.active_space.clone();
120 let restore_session = self.session.clone();
121 let restore_messages = std::mem::take(&mut self.messages);
122 let mut started = false;
123 if let Ok(Some(s)) = self.db.get_session(&w.session_id) {
124 let prior_session_id = s.id.clone();
125 self.active_space = space_row;
126 self.session = Some(s);
127 let _ = self.execute(super::AppCommand::RunResearch {
128 topic: w.topic.clone(),
129 gated: false,
130 });
131 if let Some(new_session) = self.session.as_ref()
143 && new_session.id != prior_session_id
144 {
145 let _ = self.db.set_watch_session(&w.id, &new_session.id);
146 let _ = self.db.touch_watch(&w.id, &chrono::Utc::now().to_rfc3339());
147 started = true;
148 }
149 }
150 self.active_space = restore_space;
151 self.session = restore_session;
152 self.messages = restore_messages;
153 self.refresh_toolbox();
154 started
155 }
156
157 pub fn previous_citations_for_watch_session(
166 &self,
167 session_id: &str,
168 space_id: &str,
169 ) -> anyhow::Result<Option<Vec<String>>> {
170 let Some(w) = self
171 .db
172 .list_all_watches()?
173 .into_iter()
174 .find(|w| w.session_id == session_id)
175 else {
176 return Ok(None);
177 };
178 let slug = super::sessions::slugify(&w.topic);
185 let prefix = format!("research-{slug}-");
186 let rows = self.db.search_citations(space_id, Some(&prefix))?;
187 let rows: Vec<_> = rows
188 .into_iter()
189 .filter(|(report_file, _, _)| {
190 report_file.starts_with(&prefix)
191 && report_file
192 .chars()
193 .nth(prefix.len())
194 .is_some_and(|c| c.is_ascii_digit())
195 })
196 .collect();
197 if rows.is_empty() {
198 return Ok(None);
199 }
200 Ok(Some(rows.into_iter().map(|(_, url, _)| url).collect()))
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::app::App;
208 use crate::db::Db;
209 use crate::space::Space;
210
211 fn test_app() -> App {
212 let db = Db::open_in_memory().unwrap();
213 let root =
214 std::env::temp_dir().join(format!("nexus-watches-test-{}", uuid::Uuid::new_v4()));
215 std::fs::create_dir_all(root.join("spaces")).unwrap();
216 let space = Space { root };
217 App::new(db, Some("k"), space)
218 }
219
220 #[tokio::test]
221 async fn run_due_watches_repoints_the_watch_at_its_new_session() {
222 let mut a = test_app();
223 a.current_model = Some("openai/gpt-5-mini".to_string());
224 let space_id = a.active_space.id.clone();
225
226 let first_session =
228 a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
229 .unwrap();
230 let watch_id =
231 a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
232 .unwrap();
233
234 a.run_due_watches();
235
236 let updated =
237 a.db.list_all_watches()
238 .unwrap()
239 .into_iter()
240 .find(|w| w.id == watch_id)
241 .unwrap();
242 assert_ne!(
243 updated.session_id, first_session.id,
244 "run_due_watches should repoint the watch at the session its re-run actually used, \
245 not leave it pinned to the first run's session forever"
246 );
247 assert!(updated.last_run_at.is_some());
248 }
249
250 #[tokio::test]
251 async fn run_due_watches_only_touches_the_watch_whose_job_actually_started() {
252 let mut a = test_app();
253 a.current_model = Some("openai/gpt-5-mini".to_string());
254 let space_id = a.active_space.id.clone();
255
256 let first_session =
257 a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
258 .unwrap();
259 let second_session =
260 a.db.create_session("second run", "openai/gpt-5-mini", &space_id, "chat")
261 .unwrap();
262 let watch_a =
263 a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
264 .unwrap();
265 let watch_b =
266 a.db.create_watch(&space_id, "wasm gc proposal", 24, &second_session.id)
267 .unwrap();
268
269 a.run_due_watches();
277
278 let updated_a =
279 a.db.list_all_watches()
280 .unwrap()
281 .into_iter()
282 .find(|w| w.id == watch_a)
283 .unwrap();
284 let updated_b =
285 a.db.list_all_watches()
286 .unwrap()
287 .into_iter()
288 .find(|w| w.id == watch_b)
289 .unwrap();
290
291 assert_ne!(
292 updated_a.session_id, first_session.id,
293 "watch_a's job actually started, so it should be repointed at its new session"
294 );
295 assert!(
296 updated_a.last_run_at.is_some(),
297 "watch_a's job actually started, so it should be touched"
298 );
299
300 assert_eq!(
301 updated_b.session_id, second_session.id,
302 "watch_b's job never started (guard fired) — it must not be repointed"
303 );
304 assert!(
305 updated_b.last_run_at.is_none(),
306 "watch_b's job never started (guard fired) — touching it would falsely mark it caught up \
307 and make it silently skip a full interval"
308 );
309 }
310
311 fn watch(topic: &str, interval_hours: i64, last_run_at: Option<&str>) -> Watch {
312 Watch {
313 id: "w1".to_string(),
314 space_id: "space-1".to_string(),
315 topic: topic.to_string(),
316 interval_hours,
317 session_id: "sess-1".to_string(),
318 last_run_at: last_run_at.map(str::to_string),
319 }
320 }
321
322 #[test]
323 fn never_run_watch_is_always_due() {
324 let w = watch("topic", 24, None);
325 let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T00:00:00+00:00")
326 .unwrap()
327 .to_utc();
328 assert_eq!(due_watches(&[w], now).len(), 1);
329 }
330
331 #[test]
332 fn watch_run_recently_is_not_due() {
333 let w = watch("topic", 24, Some("2026-07-07T00:00:00+00:00"));
334 let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T05:00:00+00:00")
335 .unwrap()
336 .to_utc();
337 assert!(due_watches(&[w], now).is_empty());
338 }
339
340 #[test]
341 fn watch_past_its_interval_is_due() {
342 let w = watch("topic", 24, Some("2026-07-06T00:00:00+00:00"));
343 let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T01:00:00+00:00")
344 .unwrap()
345 .to_utc();
346 assert_eq!(due_watches(&[w], now).len(), 1);
347 }
348
349 #[test]
350 fn diff_section_lists_new_sources_when_present() {
351 let section = diff_section(
352 "# Old Report\nOld body.",
353 "# New Report\nNew body.",
354 &["https://new-source.example".to_string()],
355 );
356 assert!(
357 section.contains("What changed since last run"),
358 "{section:?}"
359 );
360 assert!(
361 section.contains("https://new-source.example"),
362 "{section:?}"
363 );
364 }
365
366 #[test]
367 fn diff_section_empty_new_sources_still_produces_a_header() {
368 let section = diff_section("old", "new", &[]);
369 assert!(section.contains("What changed since last run"));
370 assert!(!section.contains("New sources"));
371 }
372
373 #[test]
374 fn new_sources_since_filters_out_previously_cited_urls() {
375 let new_report =
376 "Body [1][2].\n\n## Sources\n1. https://old.example/a\n2. https://fresh.example/b\n";
377 let previous = vec!["https://old.example/a".to_string()];
378 let new_sources = new_sources_since(new_report, &previous);
379 assert_eq!(new_sources, vec!["https://fresh.example/b".to_string()]);
380 }
381
382 #[test]
383 fn previous_citations_for_watch_session_is_scoped_to_the_watchs_own_reports() {
384 let a = test_app();
385 let space_id = a.active_space.id.clone();
386 let session =
387 a.db.create_session(
388 "rust async runtimes",
389 "openai/gpt-5-mini",
390 &space_id,
391 "chat",
392 )
393 .unwrap();
394 let watch_id =
395 a.db.create_watch(&space_id, "rust async runtimes", 24, &session.id)
396 .unwrap();
397 let _ = watch_id;
398
399 let slug = super::super::sessions::slugify("rust async runtimes");
402 a.db.add_citations(
403 &space_id,
404 &format!("research-{slug}-20260101-000000.md"),
405 &[("https://own-report.example".to_string(), None)],
406 )
407 .unwrap();
408
409 a.db.add_citations(
413 &space_id,
414 "research-some-other-topic-20260101-000000.md",
415 &[("https://unrelated.example".to_string(), None)],
416 )
417 .unwrap();
418
419 let prev = a
420 .previous_citations_for_watch_session(&session.id, &space_id)
421 .unwrap();
422 let prev = prev.expect("watch session with prior citations should yield Some");
423 assert_eq!(prev, vec!["https://own-report.example".to_string()]);
424 assert!(
425 !prev.contains(&"https://unrelated.example".to_string()),
426 "an unrelated citation elsewhere in the space must not suppress a source as \
427 already-cited for this watch: {prev:?}"
428 );
429 }
430
431 #[test]
432 fn new_sources_since_normalizes_urls_before_comparing() {
433 let new_report = "Body [1].\n\n## Sources\n1. https://Old.example/a/\n";
435 let previous = vec!["https://old.example/a".to_string()];
436 assert!(new_sources_since(new_report, &previous).is_empty());
437 }
438
439 #[test]
440 fn previous_citations_for_watch_session_prefix_collision_doesnt_match_longer_slugs() {
441 let a = test_app();
442 let space_id = a.active_space.id.clone();
443
444 let session_rust =
447 a.db.create_session("rust", "openai/gpt-5-mini", &space_id, "chat")
448 .unwrap();
449 let _watch_rust =
450 a.db.create_watch(&space_id, "rust", 24, &session_rust.id)
451 .unwrap();
452
453 let session_rust_async =
454 a.db.create_session("rust async", "openai/gpt-5-mini", &space_id, "chat")
455 .unwrap();
456 let _watch_rust_async =
457 a.db.create_watch(&space_id, "rust async", 24, &session_rust_async.id)
458 .unwrap();
459
460 let slug_rust_async = super::super::sessions::slugify("rust async");
463 a.db.add_citations(
464 &space_id,
465 &format!("research-{slug_rust_async}-20260101-000000.md"),
466 &[("https://rust-async-report.example".to_string(), None)],
467 )
468 .unwrap();
469
470 let prev_rust = a
476 .previous_citations_for_watch_session(&session_rust.id, &space_id)
477 .unwrap();
478 assert!(
479 prev_rust.is_none(),
480 "rust watch should return None (no prior citations for itself), \
481 not see rust-async watch's citations (would show prefix collision bug): {prev_rust:?}"
482 );
483 }
484}