dkp_gen_core/tools/
discovered.rs1use 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#[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 pub via: String,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub query: Option<String>,
24 pub retrieved_at: String,
26 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
49pub struct DiscoveredLog {
55 path: PathBuf,
56 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
96fn 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 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 assert_eq!(format_rfc3339(1_767_225_600), "2026-01-01T00:00:00Z");
172 }
173}