1use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, anyhow};
13use scv_client::Layout;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(tag = "kind", rename_all = "snake_case")]
20pub enum Source {
21 Files { dir: PathBuf, files: Vec<String> },
23 ScvProvider,
25}
26
27#[derive(Serialize, Deserialize)]
28struct Record {
29 source: Source,
30 digest: String,
32 imported_unix_seconds: u64,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ImportStatus {
38 pub source: Source,
39 pub imported_unix_seconds: u64,
40 pub freshness: Freshness,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Freshness {
45 Current,
47 Changed,
49 Unknown,
51}
52
53impl ImportStatus {
54 pub fn describe(&self, agent: &str, now_unix_seconds: u64) -> String {
56 let source = match &self.source {
57 Source::Files { dir, files } => {
58 format!("{} from {}", files.join(" and "), dir.display())
59 }
60 Source::ScvProvider => "SCV's provider".into(),
61 };
62 let age = age(now_unix_seconds.saturating_sub(self.imported_unix_seconds));
63 match self.freshness {
64 Freshness::Current => format!("copy of {source}, imported {age} ago, up to date"),
65 Freshness::Changed => format!(
66 "copy of {source}, imported {age} ago; the source has changed since, \
67 run `scv agents import {agent}` to refresh it"
68 ),
69 Freshness::Unknown => format!(
70 "copy of {source}, imported {age} ago; the source cannot be read here to compare"
71 ),
72 }
73 }
74}
75
76fn age(seconds: u64) -> String {
77 match seconds {
78 0..120 => format!("{seconds}s"),
79 120..7200 => format!("{}m", seconds / 60),
80 7200..172_800 => format!("{}h", seconds / 3600),
81 _ => format!("{}d", seconds / 86_400),
82 }
83}
84
85pub fn digest_files(dir: &Path, files: &[String]) -> Result<String> {
87 let mut hasher = Sha256::new();
88 for file in files {
89 hasher.update(file.as_bytes());
90 match std::fs::read(dir.join(file)) {
91 Ok(bytes) => {
92 hasher.update([1]);
93 hasher.update((bytes.len() as u64).to_le_bytes());
94 hasher.update(&bytes);
95 }
96 Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]),
97 Err(error) => {
98 return Err(anyhow!(error).context(format!("read {}", dir.join(file).display())));
99 }
100 }
101 }
102 Ok(format!("{:x}", hasher.finalize()))
103}
104
105pub fn digest_value(value: &impl Serialize) -> Result<String> {
107 Ok(format!(
108 "{:x}",
109 Sha256::digest(serde_json::to_vec(value).context("encode import digest")?)
110 ))
111}
112
113fn path(layout: &Layout, agent: &str) -> PathBuf {
114 layout.imports().join(format!("{agent}.json"))
115}
116
117pub fn record(layout: &Layout, agent: &str, source: Source, digest: String) -> Result<()> {
119 let record = Record {
120 source,
121 digest,
122 imported_unix_seconds: now(),
123 };
124 let dir = layout.imports();
125 scv_channels::state::private_directory(layout.home(), &dir)?;
126 scv_channels::state::atomic_write(&path(layout, agent), &serde_json::to_string(&record)?)
127}
128
129pub fn check(
132 layout: &Layout,
133 agent: &str,
134 current: impl FnOnce() -> Option<String>,
135) -> Result<Option<ImportStatus>> {
136 let text = match std::fs::read_to_string(path(layout, agent)) {
137 Ok(text) => text,
138 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
139 Err(error) => return Err(error.into()),
140 };
141 let record: Record = serde_json::from_str(&text)
142 .with_context(|| format!("parse {}", path(layout, agent).display()))?;
143 let now = match &record.source {
144 Source::Files { dir, files } => digest_files(dir, files).ok(),
145 Source::ScvProvider => current(),
146 };
147 let freshness = match now {
148 Some(digest) if digest == record.digest => Freshness::Current,
149 Some(_) => Freshness::Changed,
150 None => Freshness::Unknown,
151 };
152 Ok(Some(ImportStatus {
153 source: record.source,
154 imported_unix_seconds: record.imported_unix_seconds,
155 freshness,
156 }))
157}
158
159pub fn now() -> u64 {
160 std::time::SystemTime::now()
161 .duration_since(std::time::UNIX_EPOCH)
162 .map_or(0, |elapsed| elapsed.as_secs())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn a_changed_source_is_reported_until_imported_again() {
171 let home = tempfile::tempdir().unwrap();
172 let source = tempfile::tempdir().unwrap();
173 let layout = Layout::new(home.path());
174 let files = vec!["config.toml".to_owned()];
175 std::fs::write(source.path().join("config.toml"), "a = 1\n").unwrap();
176 assert!(check(&layout, "grok", || None).unwrap().is_none());
177 let files_source = Source::Files {
178 dir: source.path().to_owned(),
179 files: files.clone(),
180 };
181 let digest = digest_files(source.path(), &files).unwrap();
182 record(&layout, "grok", files_source.clone(), digest).unwrap();
183 let status = check(&layout, "grok", || None).unwrap().unwrap();
184 assert_eq!(status.freshness, Freshness::Current);
185 assert!(status.describe("grok", now()).contains("up to date"));
186
187 std::fs::write(source.path().join("config.toml"), "a = 2\n").unwrap();
188 let status = check(&layout, "grok", || None).unwrap().unwrap();
189 assert_eq!(status.freshness, Freshness::Changed);
190 assert!(
191 status
192 .describe("grok", now())
193 .contains("run `scv agents import grok`")
194 );
195
196 let digest = digest_files(source.path(), &files).unwrap();
197 record(&layout, "grok", files_source, digest).unwrap();
198 assert_eq!(
199 check(&layout, "grok", || None).unwrap().unwrap().freshness,
200 Freshness::Current
201 );
202 use std::os::unix::fs::PermissionsExt;
203 let mode = |path: PathBuf| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
204 assert_eq!(mode(layout.imports()), 0o700);
205 assert_eq!(mode(layout.imports().join("grok.json")), 0o600);
206 }
207
208 #[test]
209 fn provider_imports_compare_the_digest_the_caller_computes() {
210 let home = tempfile::tempdir().unwrap();
211 let layout = Layout::new(home.path());
212 let digest = digest_value(&("https://example.test/v1", "model", "key")).unwrap();
213 record(&layout, "scv", Source::ScvProvider, digest.clone()).unwrap();
214 let text = std::fs::read_to_string(layout.imports().join("scv.json")).unwrap();
215 assert!(!text.contains("key\""), "{text}");
216 for (current, expected) in [
217 (Some(digest), Freshness::Current),
218 (Some("other".into()), Freshness::Changed),
219 (None, Freshness::Unknown),
220 ] {
221 let status = check(&layout, "scv", || current).unwrap().unwrap();
222 assert_eq!(status.freshness, expected);
223 }
224 }
225}