1use serde::Serialize;
31
32use crate::packs::orchestration::ExecutionContext;
33use crate::preprocessing::baseline::hex_sha256;
34use crate::preprocessing::divergence::collect_baselines;
35use crate::Result;
36
37#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum RefreshAction {
41 Clean,
43 Touched,
46 MissingDeployed,
49 MissingSource,
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct RefreshEntry {
56 pub pack: String,
57 pub handler: String,
58 pub filename: String,
59 pub source_path: String,
64 pub action: RefreshAction,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct RefreshResult {
70 pub entries: Vec<RefreshEntry>,
71 pub touched_any: bool,
74 pub mode: RefreshMode,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RefreshMode {
83 Report,
85 Quiet,
87 ListPaths,
90}
91
92pub fn refresh(ctx: &ExecutionContext, mode: RefreshMode) -> Result<RefreshResult> {
103 let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
104 let mut entries = Vec::with_capacity(baselines.len());
105 let mut touched_any = false;
106
107 for (pack, handler, filename, baseline) in baselines {
108 let source_path = baseline.source_path.clone();
109 let deployed_path = ctx
110 .paths
111 .data_dir()
112 .join("packs")
113 .join(&pack)
114 .join(&handler)
115 .join(&filename);
116
117 let action = if source_path.as_os_str().is_empty() || !ctx.fs.exists(&source_path) {
118 RefreshAction::MissingSource
119 } else if !ctx.fs.exists(&deployed_path) {
120 RefreshAction::MissingDeployed
121 } else {
122 let bytes = ctx.fs.read_file(&deployed_path)?;
127 if hex_sha256(&bytes) == baseline.rendered_hash {
128 RefreshAction::Clean
129 } else {
130 if mode != RefreshMode::ListPaths {
131 let deployed_mtime = ctx.fs.modified(&deployed_path)?;
132 let source_mtime = ctx.fs.modified(&source_path)?;
133 let target = if deployed_mtime == source_mtime {
147 deployed_mtime + std::time::Duration::from_secs(1)
148 } else {
149 deployed_mtime
150 };
151 ctx.fs.set_modified(&source_path, target)?;
152 }
153 touched_any = true;
154 RefreshAction::Touched
155 }
156 };
157
158 entries.push(RefreshEntry {
159 pack,
160 handler,
161 filename,
162 source_path: source_path.display().to_string(),
163 action,
164 });
165 }
166
167 Ok(RefreshResult {
168 entries,
169 touched_any,
170 mode,
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::fs::Fs;
178 use crate::paths::Pather;
179 use crate::preprocessing::baseline::Baseline;
180 use crate::testing::TempEnvironment;
181
182 fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
183 use crate::config::ConfigManager;
184 use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
185 use std::sync::Arc;
186
187 struct NoopRunner;
188 impl CommandRunner for NoopRunner {
189 fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
190 Ok(CommandOutput {
191 exit_code: 0,
192 stdout: String::new(),
193 stderr: String::new(),
194 })
195 }
196 }
197 let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
198 let datastore = Arc::new(FilesystemDataStore::new(
199 env.fs.clone(),
200 env.paths.clone(),
201 runner.clone(),
202 ));
203 let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
204 ExecutionContext {
205 fs: env.fs.clone() as Arc<dyn Fs>,
206 datastore,
207 paths: env.paths.clone() as Arc<dyn Pather>,
208 config_manager,
209 syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
210 command_runner: runner,
211 dry_run: false,
212 no_provision: true,
213 provision_rerun: false,
214 force: false,
215 view_mode: crate::commands::ViewMode::Full,
216 group_mode: crate::commands::GroupMode::Name,
217 verbose: false,
218 }
219 }
220
221 fn write_file(env: &TempEnvironment, path: &std::path::Path, body: &[u8]) {
222 env.fs.mkdir_all(path.parent().unwrap()).unwrap();
223 env.fs.write_file(path, body).unwrap();
224 }
225
226 fn stage_one(
230 env: &TempEnvironment,
231 pack: &str,
232 template_name: &str,
233 rendered: &[u8],
234 source: &[u8],
235 ) -> (std::path::PathBuf, std::path::PathBuf) {
236 let src = env.dotfiles_root.join(pack).join(template_name);
237 write_file(env, &src, source);
238 let stripped = template_name.strip_suffix(".tmpl").unwrap_or(template_name);
239 let deployed = env
240 .paths
241 .data_dir()
242 .join("packs")
243 .join(pack)
244 .join("preprocessed")
245 .join(stripped);
246 write_file(env, &deployed, rendered);
247 let baseline = Baseline::build(&src, rendered, source, Some(""), None);
248 baseline
249 .write(
250 env.fs.as_ref(),
251 env.paths.as_ref(),
252 pack,
253 "preprocessed",
254 stripped,
255 )
256 .unwrap();
257 (src, deployed)
258 }
259
260 #[test]
261 fn empty_cache_yields_empty_report() {
262 let env = TempEnvironment::builder().build();
263 let ctx = make_ctx(&env);
264 let r = refresh(&ctx, RefreshMode::Report).unwrap();
265 assert!(r.entries.is_empty());
266 assert!(!r.touched_any);
267 }
268
269 #[test]
270 fn clean_state_is_a_noop() {
271 let env = TempEnvironment::builder().build();
273 let (src, _) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
274 let before = env.fs.modified(&src).unwrap();
277
278 let ctx = make_ctx(&env);
279 let r = refresh(&ctx, RefreshMode::Report).unwrap();
280 assert_eq!(r.entries.len(), 1);
281 assert!(matches!(r.entries[0].action, RefreshAction::Clean));
282 assert!(!r.touched_any);
283 assert_eq!(env.fs.modified(&src).unwrap(), before);
284 }
285
286 #[test]
287 fn divergent_deployed_touches_source_mtime() {
288 let env = TempEnvironment::builder().build();
291 let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
292
293 std::thread::sleep(std::time::Duration::from_millis(20));
297 env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
298 let deployed_mtime = env.fs.modified(&deployed).unwrap();
299
300 let ctx = make_ctx(&env);
301 let r = refresh(&ctx, RefreshMode::Report).unwrap();
302 assert_eq!(r.entries.len(), 1);
303 assert!(matches!(r.entries[0].action, RefreshAction::Touched));
304 assert!(r.touched_any);
305
306 let new_src_mtime = env.fs.modified(&src).unwrap();
308 assert_eq!(new_src_mtime, deployed_mtime);
309 }
310
311 #[test]
312 fn list_paths_mode_does_not_write_mtimes() {
313 let env = TempEnvironment::builder().build();
318 let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
319
320 let before_src = env.fs.modified(&src).unwrap();
321 std::thread::sleep(std::time::Duration::from_millis(20));
322 env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
323
324 let ctx = make_ctx(&env);
325 let r = refresh(&ctx, RefreshMode::ListPaths).unwrap();
326 assert_eq!(r.entries.len(), 1);
327 assert!(matches!(r.entries[0].action, RefreshAction::Touched));
328 assert!(r.touched_any);
329
330 assert_eq!(env.fs.modified(&src).unwrap(), before_src);
332 }
333
334 #[test]
335 fn quiet_mode_still_writes_mtimes() {
336 let env = TempEnvironment::builder().build();
339 let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
340
341 std::thread::sleep(std::time::Duration::from_millis(20));
342 env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
343 let deployed_mtime = env.fs.modified(&deployed).unwrap();
344
345 let ctx = make_ctx(&env);
346 let r = refresh(&ctx, RefreshMode::Quiet).unwrap();
347 assert!(matches!(r.entries[0].action, RefreshAction::Touched));
348 assert_eq!(env.fs.modified(&src).unwrap(), deployed_mtime);
349 }
350
351 #[test]
352 fn missing_source_is_reported_not_an_error() {
353 let env = TempEnvironment::builder().build();
357 let baseline = Baseline::build(
359 &env.dotfiles_root.join("app/missing.toml.tmpl"),
360 b"rendered",
361 b"src",
362 Some(""),
363 None,
364 );
365 baseline
366 .write(
367 env.fs.as_ref(),
368 env.paths.as_ref(),
369 "app",
370 "preprocessed",
371 "missing.toml",
372 )
373 .unwrap();
374 let deployed = env
376 .paths
377 .data_dir()
378 .join("packs/app/preprocessed/missing.toml");
379 write_file(&env, &deployed, b"rendered");
380
381 let ctx = make_ctx(&env);
382 let r = refresh(&ctx, RefreshMode::Report).unwrap();
383 assert_eq!(r.entries.len(), 1);
384 assert!(matches!(r.entries[0].action, RefreshAction::MissingSource));
385 assert!(!r.touched_any);
386 }
387
388 #[test]
389 fn missing_deployed_is_reported_not_an_error() {
390 let env = TempEnvironment::builder().build();
393 let src = env.dotfiles_root.join("app/cfg.toml.tmpl");
394 write_file(&env, &src, b"src");
395 let baseline = Baseline::build(&src, b"rendered", b"src", Some(""), None);
396 baseline
397 .write(
398 env.fs.as_ref(),
399 env.paths.as_ref(),
400 "app",
401 "preprocessed",
402 "cfg.toml",
403 )
404 .unwrap();
405 let ctx = make_ctx(&env);
408 let r = refresh(&ctx, RefreshMode::Report).unwrap();
409 assert!(matches!(
410 r.entries[0].action,
411 RefreshAction::MissingDeployed
412 ));
413 assert!(!r.touched_any);
414 }
415
416 #[test]
417 fn pure_data_edit_is_still_treated_as_divergent() {
418 let env = TempEnvironment::builder().build();
427 let (_src, deployed) = stage_one(
428 &env,
429 "app",
430 "greet.tmpl",
431 b"hello Alice",
432 b"hello {{ name }}",
433 );
434 std::thread::sleep(std::time::Duration::from_millis(20));
435 env.fs.write_file(&deployed, b"hello Bob").unwrap();
436
437 let ctx = make_ctx(&env);
438 let r = refresh(&ctx, RefreshMode::Report).unwrap();
439 assert!(matches!(r.entries[0].action, RefreshAction::Touched));
440 assert!(r.touched_any);
441 }
442
443 #[test]
444 fn divergent_with_equal_mtimes_still_bumps_source() {
445 let env = TempEnvironment::builder().build();
452 let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
453
454 let pinned = env.fs.modified(&src).unwrap();
458 env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
459 env.fs.set_modified(&deployed, pinned).unwrap();
460 assert_eq!(env.fs.modified(&deployed).unwrap(), pinned);
461
462 let ctx = make_ctx(&env);
463 let r = refresh(&ctx, RefreshMode::Report).unwrap();
464 assert!(matches!(r.entries[0].action, RefreshAction::Touched));
465
466 let after = env.fs.modified(&src).unwrap();
469 assert!(
470 after > pinned,
471 "source mtime should strictly increase even when deployed mtime equals source mtime"
472 );
473 }
474
475 #[test]
476 fn entries_are_sorted_by_pack_handler_filename() {
477 let env = TempEnvironment::builder().build();
481 for (pack, name) in [
482 ("zebra", "z.tmpl"),
483 ("alpha", "b.tmpl"),
484 ("alpha", "a.tmpl"),
485 ] {
486 stage_one(&env, pack, name, b"rendered", b"src");
487 }
488 let ctx = make_ctx(&env);
489 let r = refresh(&ctx, RefreshMode::Report).unwrap();
490 let order: Vec<_> = r
491 .entries
492 .iter()
493 .map(|e| (e.pack.clone(), e.filename.clone()))
494 .collect();
495 assert_eq!(
496 order,
497 vec![
498 ("alpha".into(), "a".into()),
499 ("alpha".into(), "b".into()),
500 ("zebra".into(), "z".into()),
501 ]
502 );
503 }
504}