Skip to main content

dkp_gen_core/tools/
discovered.rs

1use std::collections::HashSet;
2use std::fs::OpenOptions;
3use std::io::Write;
4use std::path::PathBuf;
5use std::sync::Mutex;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::GenResult;
11
12/// One tool-discovered URL, staged for later human review and promotion
13/// into `evidence/sources.csv` via `dkp rights add-source --from-discovered`.
14/// Never written directly to `sources.csv` — see `.docs/GEN_AGENTS.md` §3.5.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DiscoveredSource {
17    pub url: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub title: Option<String>,
20    /// "web_fetch" | "web_search"
21    pub via: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub query: Option<String>,
24    /// RFC3339 timestamp.
25    pub retrieved_at: String,
26    /// "generate" | "fix"
27    pub command: String,
28}
29
30impl DiscoveredSource {
31    pub fn now(
32        url: String,
33        title: Option<String>,
34        via: impl Into<String>,
35        query: Option<String>,
36        command: impl Into<String>,
37    ) -> Self {
38        Self {
39            url,
40            title,
41            via: via.into(),
42            query,
43            retrieved_at: rfc3339_now(),
44            command: command.into(),
45        }
46    }
47}
48
49/// Append-only staging log at `build/sources_discovered.jsonl`.
50///
51/// Uses append-mode writes rather than the pipeline's usual atomic
52/// tmp-then-rename helper, since this file is appended to incrementally
53/// during a single tool-use loop rather than replaced wholesale.
54pub struct DiscoveredLog {
55    path: PathBuf,
56    // Guards both the dedupe set and the underlying file handle so
57    // concurrent tool calls append one full line at a time.
58    seen: Mutex<HashSet<String>>,
59}
60
61impl DiscoveredLog {
62    pub fn new(path: PathBuf) -> Self {
63        Self {
64            path,
65            seen: Mutex::new(HashSet::new()),
66        }
67    }
68
69    pub fn append(&self, entry: &DiscoveredSource) -> GenResult<()> {
70        let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
71        if !seen.insert(entry.url.clone()) {
72            return Ok(());
73        }
74        if let Some(parent) = self.path.parent() {
75            std::fs::create_dir_all(parent)?;
76        }
77        let mut line = serde_json::to_string(entry)?;
78        line.push('\n');
79        let mut file = OpenOptions::new()
80            .create(true)
81            .append(true)
82            .open(&self.path)?;
83        file.write_all(line.as_bytes())?;
84        Ok(())
85    }
86}
87
88fn rfc3339_now() -> String {
89    let secs = SystemTime::now()
90        .duration_since(UNIX_EPOCH)
91        .map(|d| d.as_secs())
92        .unwrap_or(0);
93    format_rfc3339(secs)
94}
95
96/// Minimal UTC RFC3339 (seconds precision) formatter — avoids adding a
97/// chrono/time dependency for a single timestamp field.
98fn format_rfc3339(unix_secs: u64) -> String {
99    const DAYS_PER_400Y: i64 = 146_097;
100    let days_since_epoch = (unix_secs / 86_400) as i64;
101    let secs_of_day = unix_secs % 86_400;
102    let (hour, min, sec) = (
103        secs_of_day / 3600,
104        (secs_of_day / 60) % 60,
105        secs_of_day % 60,
106    );
107
108    // Civil-from-days algorithm (Howard Hinnant), epoch = 1970-01-01.
109    let z = days_since_epoch + 719_468;
110    let era = if z >= 0 { z } else { z - DAYS_PER_400Y + 1 } / DAYS_PER_400Y;
111    let doe = (z - era * DAYS_PER_400Y) as u64;
112    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
113    let y = yoe as i64 + era * 400;
114    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
115    let mp = (5 * doy + 2) / 153;
116    let d = doy - (153 * mp + 2) / 5 + 1;
117    let m = if mp < 10 { mp + 3 } else { mp - 9 };
118    let y = if m <= 2 { y + 1 } else { y };
119
120    format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use tempfile::TempDir;
127
128    #[test]
129    fn append_writes_jsonl_line() {
130        let tmp = TempDir::new().unwrap();
131        let log = DiscoveredLog::new(tmp.path().join("build/sources_discovered.jsonl"));
132        log.append(&DiscoveredSource::now(
133            "https://example.com".to_string(),
134            Some("Example".to_string()),
135            "web_fetch",
136            None,
137            "generate",
138        ))
139        .unwrap();
140        let content =
141            std::fs::read_to_string(tmp.path().join("build/sources_discovered.jsonl")).unwrap();
142        assert_eq!(content.lines().count(), 1);
143        let parsed: DiscoveredSource =
144            serde_json::from_str(content.lines().next().unwrap()).unwrap();
145        assert_eq!(parsed.url, "https://example.com");
146        assert_eq!(parsed.via, "web_fetch");
147    }
148
149    #[test]
150    fn append_dedupes_repeated_urls() {
151        let tmp = TempDir::new().unwrap();
152        let log = DiscoveredLog::new(tmp.path().join("build/sources_discovered.jsonl"));
153        for _ in 0..3 {
154            log.append(&DiscoveredSource::now(
155                "https://example.com".to_string(),
156                None,
157                "web_fetch",
158                None,
159                "generate",
160            ))
161            .unwrap();
162        }
163        let content =
164            std::fs::read_to_string(tmp.path().join("build/sources_discovered.jsonl")).unwrap();
165        assert_eq!(content.lines().count(), 1);
166    }
167
168    #[test]
169    fn rfc3339_formats_known_epoch_seconds() {
170        // 2026-01-01T00:00:00Z
171        assert_eq!(format_rfc3339(1_767_225_600), "2026-01-01T00:00:00Z");
172    }
173}