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_memory_snapshot = self.memory_snapshot.clone();
122 let restore_cache_epoch = self.cache_epoch;
123 let restore_messages = std::mem::take(&mut self.messages);
124 let mut started = false;
125 if let Ok(Some(s)) = self.db.get_session(&w.session_id) {
126 let prior_session_id = s.id.clone();
127 self.active_space = space_row;
128 self.session = Some(s);
129 let _ = self.execute(super::AppCommand::RunResearch {
130 topic: w.topic.clone(),
131 gated: false,
132 });
133 if let Some(new_session) = self.session.as_ref()
145 && new_session.id != prior_session_id
146 {
147 let _ = self.db.set_watch_session(&w.id, &new_session.id);
148 let _ = self.db.touch_watch(&w.id, &chrono::Utc::now().to_rfc3339());
149 started = true;
150 }
151 }
152 self.active_space = restore_space;
153 self.session = restore_session;
154 self.memory_snapshot = restore_memory_snapshot;
155 self.cache_epoch = restore_cache_epoch;
156 self.messages = restore_messages;
157 self.refresh_toolbox();
158 started
159 }
160
161 pub fn previous_citations_for_watch_session(
170 &self,
171 session_id: &str,
172 space_id: &str,
173 ) -> anyhow::Result<Option<Vec<String>>> {
174 let Some(w) = self
175 .db
176 .list_all_watches()?
177 .into_iter()
178 .find(|w| w.session_id == session_id)
179 else {
180 return Ok(None);
181 };
182 let slug = super::sessions::slugify(&w.topic);
189 let prefix = format!("research-{slug}-");
190 let rows = self.db.search_citations(space_id, Some(&prefix))?;
191 let rows: Vec<_> = rows
192 .into_iter()
193 .filter(|(report_file, _, _)| {
194 report_file.starts_with(&prefix)
195 && report_file
196 .chars()
197 .nth(prefix.len())
198 .is_some_and(|c| c.is_ascii_digit())
199 })
200 .collect();
201 if rows.is_empty() {
202 return Ok(None);
203 }
204 Ok(Some(rows.into_iter().map(|(_, url, _)| url).collect()))
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::app::App;
212 use crate::db::Db;
213 use crate::space::Space;
214
215 fn test_app() -> App {
216 let db = Db::open_in_memory().unwrap();
217 let root =
218 std::env::temp_dir().join(format!("nexus-watches-test-{}", uuid::Uuid::new_v4()));
219 std::fs::create_dir_all(root.join("spaces")).unwrap();
220 let space = Space { root };
221 App::new(db, Some("k"), space)
222 }
223
224 #[tokio::test]
225 async fn run_due_watches_repoints_the_watch_at_its_new_session() {
226 let mut a = test_app();
227 a.current_model = Some("openai/gpt-5-mini".to_string());
228 let space_id = a.active_space.id.clone();
229
230 let first_session =
232 a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
233 .unwrap();
234 let watch_id =
235 a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
236 .unwrap();
237
238 a.run_due_watches();
239
240 let updated =
241 a.db.list_all_watches()
242 .unwrap()
243 .into_iter()
244 .find(|w| w.id == watch_id)
245 .unwrap();
246 assert_ne!(
247 updated.session_id, first_session.id,
248 "run_due_watches should repoint the watch at the session its re-run actually used, \
249 not leave it pinned to the first run's session forever"
250 );
251 assert!(updated.last_run_at.is_some());
252 }
253
254 #[tokio::test]
255 async fn run_due_watches_only_touches_the_watch_whose_job_actually_started() {
256 let mut a = test_app();
257 a.current_model = Some("openai/gpt-5-mini".to_string());
258 let space_id = a.active_space.id.clone();
259
260 let first_session =
261 a.db.create_session("first run", "openai/gpt-5-mini", &space_id, "chat")
262 .unwrap();
263 let second_session =
264 a.db.create_session("second run", "openai/gpt-5-mini", &space_id, "chat")
265 .unwrap();
266 let watch_a =
267 a.db.create_watch(&space_id, "rust async runtimes", 24, &first_session.id)
268 .unwrap();
269 let watch_b =
270 a.db.create_watch(&space_id, "wasm gc proposal", 24, &second_session.id)
271 .unwrap();
272
273 a.run_due_watches();
281
282 let updated_a =
283 a.db.list_all_watches()
284 .unwrap()
285 .into_iter()
286 .find(|w| w.id == watch_a)
287 .unwrap();
288 let updated_b =
289 a.db.list_all_watches()
290 .unwrap()
291 .into_iter()
292 .find(|w| w.id == watch_b)
293 .unwrap();
294
295 assert_ne!(
296 updated_a.session_id, first_session.id,
297 "watch_a's job actually started, so it should be repointed at its new session"
298 );
299 assert!(
300 updated_a.last_run_at.is_some(),
301 "watch_a's job actually started, so it should be touched"
302 );
303
304 assert_eq!(
305 updated_b.session_id, second_session.id,
306 "watch_b's job never started (guard fired) — it must not be repointed"
307 );
308 assert!(
309 updated_b.last_run_at.is_none(),
310 "watch_b's job never started (guard fired) — touching it would falsely mark it caught up \
311 and make it silently skip a full interval"
312 );
313 }
314
315 fn watch(topic: &str, interval_hours: i64, last_run_at: Option<&str>) -> Watch {
316 Watch {
317 id: "w1".to_string(),
318 space_id: "space-1".to_string(),
319 topic: topic.to_string(),
320 interval_hours,
321 session_id: "sess-1".to_string(),
322 last_run_at: last_run_at.map(str::to_string),
323 }
324 }
325
326 #[test]
327 fn never_run_watch_is_always_due() {
328 let w = watch("topic", 24, None);
329 let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T00:00:00+00:00")
330 .unwrap()
331 .to_utc();
332 assert_eq!(due_watches(&[w], now).len(), 1);
333 }
334
335 #[test]
336 fn watch_run_recently_is_not_due() {
337 let w = watch("topic", 24, Some("2026-07-07T00:00:00+00:00"));
338 let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T05:00:00+00:00")
339 .unwrap()
340 .to_utc();
341 assert!(due_watches(&[w], now).is_empty());
342 }
343
344 #[test]
345 fn watch_past_its_interval_is_due() {
346 let w = watch("topic", 24, Some("2026-07-06T00:00:00+00:00"));
347 let now = chrono::DateTime::parse_from_rfc3339("2026-07-07T01:00:00+00:00")
348 .unwrap()
349 .to_utc();
350 assert_eq!(due_watches(&[w], now).len(), 1);
351 }
352
353 #[test]
354 fn diff_section_lists_new_sources_when_present() {
355 let section = diff_section(
356 "# Old Report\nOld body.",
357 "# New Report\nNew body.",
358 &["https://new-source.example".to_string()],
359 );
360 assert!(
361 section.contains("What changed since last run"),
362 "{section:?}"
363 );
364 assert!(
365 section.contains("https://new-source.example"),
366 "{section:?}"
367 );
368 }
369
370 #[test]
371 fn diff_section_empty_new_sources_still_produces_a_header() {
372 let section = diff_section("old", "new", &[]);
373 assert!(section.contains("What changed since last run"));
374 assert!(!section.contains("New sources"));
375 }
376
377 #[test]
378 fn new_sources_since_filters_out_previously_cited_urls() {
379 let new_report =
380 "Body [1][2].\n\n## Sources\n1. https://old.example/a\n2. https://fresh.example/b\n";
381 let previous = vec!["https://old.example/a".to_string()];
382 let new_sources = new_sources_since(new_report, &previous);
383 assert_eq!(new_sources, vec!["https://fresh.example/b".to_string()]);
384 }
385
386 #[test]
387 fn previous_citations_for_watch_session_is_scoped_to_the_watchs_own_reports() {
388 let a = test_app();
389 let space_id = a.active_space.id.clone();
390 let session =
391 a.db.create_session(
392 "rust async runtimes",
393 "openai/gpt-5-mini",
394 &space_id,
395 "chat",
396 )
397 .unwrap();
398 let watch_id =
399 a.db.create_watch(&space_id, "rust async runtimes", 24, &session.id)
400 .unwrap();
401 let _ = watch_id;
402
403 let slug = super::super::sessions::slugify("rust async runtimes");
406 a.db.add_citations(
407 &space_id,
408 &format!("research-{slug}-20260101-000000.md"),
409 &[("https://own-report.example".to_string(), None)],
410 )
411 .unwrap();
412
413 a.db.add_citations(
417 &space_id,
418 "research-some-other-topic-20260101-000000.md",
419 &[("https://unrelated.example".to_string(), None)],
420 )
421 .unwrap();
422
423 let prev = a
424 .previous_citations_for_watch_session(&session.id, &space_id)
425 .unwrap();
426 let prev = prev.expect("watch session with prior citations should yield Some");
427 assert_eq!(prev, vec!["https://own-report.example".to_string()]);
428 assert!(
429 !prev.contains(&"https://unrelated.example".to_string()),
430 "an unrelated citation elsewhere in the space must not suppress a source as \
431 already-cited for this watch: {prev:?}"
432 );
433 }
434
435 #[test]
436 fn new_sources_since_normalizes_urls_before_comparing() {
437 let new_report = "Body [1].\n\n## Sources\n1. https://Old.example/a/\n";
439 let previous = vec!["https://old.example/a".to_string()];
440 assert!(new_sources_since(new_report, &previous).is_empty());
441 }
442
443 #[test]
444 fn previous_citations_for_watch_session_prefix_collision_doesnt_match_longer_slugs() {
445 let a = test_app();
446 let space_id = a.active_space.id.clone();
447
448 let session_rust =
451 a.db.create_session("rust", "openai/gpt-5-mini", &space_id, "chat")
452 .unwrap();
453 let _watch_rust =
454 a.db.create_watch(&space_id, "rust", 24, &session_rust.id)
455 .unwrap();
456
457 let session_rust_async =
458 a.db.create_session("rust async", "openai/gpt-5-mini", &space_id, "chat")
459 .unwrap();
460 let _watch_rust_async =
461 a.db.create_watch(&space_id, "rust async", 24, &session_rust_async.id)
462 .unwrap();
463
464 let slug_rust_async = super::super::sessions::slugify("rust async");
467 a.db.add_citations(
468 &space_id,
469 &format!("research-{slug_rust_async}-20260101-000000.md"),
470 &[("https://rust-async-report.example".to_string(), None)],
471 )
472 .unwrap();
473
474 let prev_rust = a
480 .previous_citations_for_watch_session(&session_rust.id, &space_id)
481 .unwrap();
482 assert!(
483 prev_rust.is_none(),
484 "rust watch should return None (no prior citations for itself), \
485 not see rust-async watch's citations (would show prefix collision bug): {prev_rust:?}"
486 );
487 }
488}