1use std::collections::BTreeMap;
33use std::path::Path;
34
35use kimetsu_core::KimetsuResult;
36use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Pass {
41 Reinforce,
43 Digest,
45 Prune,
48 Skills,
50}
51
52impl Pass {
53 pub const ALL: [Pass; 4] = [Pass::Reinforce, Pass::Digest, Pass::Prune, Pass::Skills];
54
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Pass::Reinforce => "reinforce",
58 Pass::Digest => "digest",
59 Pass::Prune => "prune",
60 Pass::Skills => "skills",
61 }
62 }
63
64 pub fn interval_secs(self) -> u64 {
70 match self {
71 Pass::Reinforce => 24 * 60 * 60,
74 Pass::Digest => 12 * 60 * 60,
77 Pass::Prune => 7 * 24 * 60 * 60,
78 Pass::Skills => 24 * 60 * 60,
79 }
80 }
81}
82
83impl std::str::FromStr for Pass {
84 type Err = String;
85
86 fn from_str(value: &str) -> Result<Self, Self::Err> {
87 match value.trim().to_ascii_lowercase().as_str() {
88 "reinforce" => Ok(Pass::Reinforce),
89 "digest" => Ok(Pass::Digest),
90 "prune" => Ok(Pass::Prune),
91 "skills" => Ok(Pass::Skills),
92 other => Err(format!("unknown maintenance pass `{other}`")),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct MaintenanceState {
100 #[serde(default)]
102 pub last_run: BTreeMap<String, u64>,
103}
104
105impl MaintenanceState {
106 pub fn last_run(&self, pass: Pass) -> Option<u64> {
107 self.last_run.get(pass.as_str()).copied()
108 }
109
110 pub fn mark_ran(&mut self, pass: Pass, now: u64) {
111 self.last_run.insert(pass.as_str().to_string(), now);
112 }
113}
114
115pub fn state_path(kimetsu_dir: &Path) -> std::path::PathBuf {
116 kimetsu_dir.join("maintenance.json")
117}
118
119pub fn load_state(kimetsu_dir: &Path) -> MaintenanceState {
123 std::fs::read_to_string(state_path(kimetsu_dir))
124 .ok()
125 .and_then(|text| serde_json::from_str(&text).ok())
126 .unwrap_or_default()
127}
128
129pub fn save_state(kimetsu_dir: &Path, state: &MaintenanceState) -> KimetsuResult<()> {
130 let path = state_path(kimetsu_dir);
131 if let Some(parent) = path.parent() {
132 std::fs::create_dir_all(parent)?;
133 }
134 let tmp = path.with_extension("json.tmp");
135 std::fs::write(&tmp, serde_json::to_string_pretty(state)?)?;
136 std::fs::rename(&tmp, &path)?;
137 Ok(())
138}
139
140pub fn due_passes(state: &MaintenanceState, now: u64) -> Vec<Pass> {
143 Pass::ALL
144 .into_iter()
145 .filter(|pass| match state.last_run(*pass) {
146 None => true,
147 Some(last) => now.saturating_sub(last) >= pass.interval_secs(),
148 })
149 .collect()
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct PassOutcome {
155 pub pass: String,
156 pub detail: String,
158 pub ok: bool,
161}
162
163pub fn run_passes(start: &Path, passes: &[Pass]) -> Vec<PassOutcome> {
168 passes.iter().map(|pass| run_pass(start, *pass)).collect()
169}
170
171fn run_pass(start: &Path, pass: Pass) -> PassOutcome {
172 let detail = match pass {
173 Pass::Reinforce => crate::reinforce::reinforce(start, true, true).map(|summary| {
174 format!(
175 "{} staple(s), {} route(s)",
176 summary.staples_created, summary.routes_built
177 )
178 }),
179 Pass::Digest => Ok(match crate::digest::build_or_load_digest(start, true) {
180 Some(digest) => format!("rebuilt ({} chars)", digest.len()),
181 None => "nothing to digest yet".to_string(),
182 }),
183 Pass::Prune => crate::maintenance::prune_low_usefulness(
184 start,
185 crate::maintenance::PruneOptions {
186 apply: false,
189 ..Default::default()
190 },
191 )
192 .map(|summary| format!("{} prune candidate(s)", summary.candidates.len())),
193 Pass::Skills => crate::project::load_project_readonly(start).and_then(|(_p, _c, conn)| {
194 crate::skill_synthesis::find_synthesis_candidates(&conn)
195 .map(|candidates| format!("{} skill candidate(s)", candidates.len()))
196 }),
197 };
198
199 match detail {
200 Ok(detail) => PassOutcome {
201 pass: pass.as_str().to_string(),
202 detail,
203 ok: true,
204 },
205 Err(err) => PassOutcome {
206 pass: pass.as_str().to_string(),
207 detail: err.to_string(),
208 ok: false,
209 },
210 }
211}
212
213pub fn now_unix() -> u64 {
215 std::time::SystemTime::now()
216 .duration_since(std::time::UNIX_EPOCH)
217 .map(|d| d.as_secs())
218 .unwrap_or(0)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn everything_is_due_on_a_brain_that_has_never_run_upkeep() {
227 let state = MaintenanceState::default();
228 assert_eq!(due_passes(&state, 1_000_000), Pass::ALL.to_vec());
229 }
230
231 #[test]
232 fn a_pass_that_just_ran_is_not_due() {
233 let now = 1_000_000;
234 let mut state = MaintenanceState::default();
235 state.mark_ran(Pass::Reinforce, now);
236 let due = due_passes(&state, now + 60);
237 assert!(!due.contains(&Pass::Reinforce), "got: {due:?}");
238 assert!(
239 due.contains(&Pass::Digest),
240 "the others are untouched: {due:?}"
241 );
242 }
243
244 #[test]
245 fn a_pass_becomes_due_again_after_its_interval() {
246 let now = 1_000_000;
247 let mut state = MaintenanceState::default();
248 state.mark_ran(Pass::Reinforce, now);
249 assert!(
250 !due_passes(&state, now + Pass::Reinforce.interval_secs() - 1)
251 .contains(&Pass::Reinforce)
252 );
253 assert!(
254 due_passes(&state, now + Pass::Reinforce.interval_secs()).contains(&Pass::Reinforce)
255 );
256 }
257
258 #[test]
261 fn a_backwards_clock_does_not_wedge_the_schedule() {
262 let mut state = MaintenanceState::default();
263 state.mark_ran(Pass::Digest, 2_000_000);
264 let due = due_passes(&state, 1_000_000);
265 assert!(
266 !due.contains(&Pass::Digest),
267 "saturating_sub yields 0, so it is simply not yet due: {due:?}"
268 );
269 }
270
271 #[test]
272 fn state_round_trips_and_a_corrupt_file_makes_everything_due() {
273 let dir = std::env::temp_dir().join(format!("kimetsu-maintain-{}", now_unix()));
274 std::fs::create_dir_all(&dir).expect("mkdir");
275
276 let mut state = MaintenanceState::default();
277 state.mark_ran(Pass::Skills, 42);
278 save_state(&dir, &state).expect("save");
279 assert_eq!(load_state(&dir).last_run(Pass::Skills), Some(42));
280
281 std::fs::write(state_path(&dir), "{ not json").expect("corrupt");
282 assert_eq!(
283 due_passes(&load_state(&dir), 1_000_000),
284 Pass::ALL.to_vec(),
285 "a corrupt file must fail towards running upkeep, not away from it"
286 );
287
288 std::fs::remove_dir_all(&dir).ok();
289 }
290
291 #[test]
292 fn pass_names_round_trip() {
293 for pass in Pass::ALL {
294 assert_eq!(pass.as_str().parse::<Pass>(), Ok(pass));
295 }
296 assert!("reflect".parse::<Pass>().is_err(), "Deep-tier, not upkeep");
297 }
298
299 #[test]
303 fn passes_are_best_effort_against_a_missing_brain() {
304 let dir = std::env::temp_dir().join(format!("kimetsu-maintain-nobrain-{}", now_unix()));
305 std::fs::create_dir_all(&dir).expect("mkdir");
306 let outcomes = run_passes(&dir, &Pass::ALL);
307 assert_eq!(outcomes.len(), Pass::ALL.len(), "every pass is attempted");
308 std::fs::remove_dir_all(&dir).ok();
309 }
310}