1use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{anyhow, bail, Context, Result};
12use chrono::{DateTime, Datelike, NaiveDate, Utc};
13
14use super::hasher::{self, atomic_write, sha256_bytes, sha256_file};
15use super::lock::RootLock;
16use super::manifest::{
17 AgentInfo, Artifact, BySystemBlock, Manifest, PrepareContext, PriorRun, RunOutcome, RunResult,
18 ScopeLayout, SkillRef, SystemOutcome,
19};
20use crate::model::{Cadence, LoadedRegistry, RunStatus, StateEntry};
21use crate::registry::{period, resolver};
22use crate::SCHEMA_VERSION;
23
24const PENDING_SENTINEL: &str = ".run-pending";
25const PREPARE_FILE: &str = "prepare.json";
26const RESULT_FILE: &str = "result.json";
27const MANIFEST_FILE: &str = "manifest.json";
28const STATE_FILE: &str = "state.json";
29
30#[derive(Debug, Clone, Default)]
33pub struct PrepareOpts {
34 pub today: Option<NaiveDate>,
35 pub operator: Option<String>,
36 pub note: Option<String>,
37 pub now: Option<DateTime<Utc>>,
38 pub period_id: Option<String>,
41}
42
43pub fn prepare(
47 reg: &LoadedRegistry,
48 control_id: &str,
49 opts: &PrepareOpts,
50) -> Result<PrepareContext> {
51 let _lock = RootLock::acquire(®.root).context("acquire root lock")?;
52 let ctrl = reg
53 .controls
54 .get(control_id)
55 .ok_or_else(|| anyhow!("control `{control_id}` not found"))?;
56
57 let now = opts.now.unwrap_or_else(Utc::now);
58 let today = opts.today.unwrap_or_else(|| now.date_naive());
59
60 let existing = list_pending(®.root)?;
62 if let Some(p) = existing.iter().find(|r| r.control_id == control_id) {
63 bail!(
64 "pending run already exists for `{}` at {}",
65 control_id,
66 p.run_dir.display()
67 );
68 }
69
70 let resolved = resolver::resolve_scope(ctrl, ®.inventory, today);
71
72 if ctrl.scope.is_some() && resolved.is_empty() {
78 let scope_desc = match &ctrl.scope {
79 Some(crate::model::Scope::Inventory(s)) => {
80 format!("kind: {}, has_tags: {:?}", s.kind, s.has_tags)
81 }
82 Some(crate::model::Scope::Inline(_)) => "inline".to_string(),
83 None => unreachable!(),
84 };
85 bail!(
86 "control `{control_id}` has scope ({scope_desc}) but no inventory entries match on {today}. Either update inventory.yaml or retire the control."
87 );
88 }
89
90 let scope_layout = if ctrl.scope.is_none() || resolved.len() == 1 {
94 ScopeLayout::Flat
95 } else {
96 ScopeLayout::BySystem
97 };
98
99 let period_id = match &opts.period_id {
102 Some(supplied) => {
103 if matches!(ctrl.cadence, Cadence::Continuous) {
104 bail!(
105 "control `{control_id}` has continuous cadence and does not have schedule periods; --period is not allowed"
106 );
107 }
108 let canonical = period::canonicalize(ctrl.cadence, supplied);
113 if period::bounds(ctrl.cadence, &canonical).is_none() {
114 bail!(
115 "`{supplied}` is not a valid period id for cadence {:?}",
116 ctrl.cadence
117 );
118 }
119 Some(canonical)
120 }
121 None => {
122 if matches!(ctrl.cadence, Cadence::Continuous) {
123 None
124 } else {
125 period::derive(ctrl.cadence, today)
131 }
132 }
133 };
134
135 let run_dir = allocate_run_dir(®.root, control_id, today)?;
136 if matches!(scope_layout, ScopeLayout::BySystem) {
137 for sys in &resolved {
138 fs::create_dir_all(run_dir.join("by-system").join(&sys.name).join("raw"))?;
139 }
140 } else {
141 fs::create_dir_all(run_dir.join("raw"))?;
142 }
143
144 let registry_git_sha = git_head(®.root).with_context(|| {
145 format!(
146 "{} is not a git repository — `cd` into a checked-out registry, or `git init && git commit` if starting fresh",
147 reg.root.display()
148 )
149 })?;
150
151 let skill = {
155 let r = crate::skills::resolve(®.root, &ctrl.skill).ok_or_else(|| {
156 anyhow!(
157 "skill `{}` not found (no local skills/{}.md and not bundled)",
158 ctrl.skill,
159 ctrl.skill
160 )
161 })?;
162 SkillRef {
163 name: r.name,
164 source: r.source.as_str().to_string(),
165 sha256: r.sha256,
166 }
167 };
168
169 let ctx = PrepareContext {
170 schema_version: SCHEMA_VERSION,
171 control_id: control_id.to_string(),
172 run_id: run_id_from_dir(&run_dir)?,
173 run_dir: run_dir.clone(),
174 started_at: now,
175 skill,
176 operator: opts.operator.clone(),
177 note: opts.note.clone(),
178 scope_layout,
179 resolved_scope: resolved,
180 registry_git_sha,
181 period_id,
182 };
183
184 let json = serde_json::to_vec_pretty(&ctx)?;
185 atomic_write(&run_dir.join(PREPARE_FILE), &json)?;
186 atomic_write(&run_dir.join(PENDING_SENTINEL), b"")?;
188 Ok(ctx)
189}
190
191pub fn resume(run_dir: &Path) -> Result<PrepareContext> {
194 let path = run_dir.join(PREPARE_FILE);
195 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
196 let ctx: PrepareContext = serde_json::from_slice(&bytes)?;
197 Ok(ctx)
198}
199
200pub fn abort(reg: &LoadedRegistry, run_dir: &Path, reason: &str) -> Result<Manifest> {
204 let _lock = RootLock::acquire(®.root).context("acquire root lock")?;
205
206 let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
207 let ctrl = reg
208 .controls
209 .get(&prepare.control_id)
210 .ok_or_else(|| anyhow!("control `{}` not found", prepare.control_id))?;
211
212 let prior_run = prior_run_link(®.root, &prepare.control_id, &prepare.run_id)?;
213 let control_sha256 = sha256_for_control(®.root, &prepare.control_id)?;
214 let skill_sha256 = sha256_for_skill(®.root, &ctrl.skill)?;
215
216 let manifest = Manifest {
217 schema_version: SCHEMA_VERSION,
218 control_id: prepare.control_id.clone(),
219 run_id: prepare.run_id.clone(),
220 started_at: prepare.started_at,
221 completed_at: Utc::now(),
222 operator: prepare.operator.clone(),
223 agent: AgentInfo {
224 model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
225 skill: ctrl.skill.clone(),
226 skill_sha256,
227 control_sha256,
228 },
229 registry_git_sha: prepare.registry_git_sha.clone(),
230 scope_layout: prepare.scope_layout,
231 resolved_scope: prepare.resolved_scope.clone(),
232 prior_run,
233 artifacts: Vec::new(),
234 by_system: Vec::new(),
235 status: RunOutcome::Failed,
236 failure_reason: Some(reason.to_string()),
237 draft_risks: Vec::new(),
238 draft_issues: Vec::new(),
239 external_links: Vec::new(),
240 period_id: prepare.period_id.clone(),
241 };
242
243 let bytes = serde_json::to_vec(&manifest)?;
244 atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
245 update_state(reg, &manifest)?;
246
247 let pending = run_dir.join(PENDING_SENTINEL);
248 if pending.exists() {
249 fs::remove_file(&pending)?;
250 }
251 Ok(manifest)
252}
253
254pub fn finalize(reg: &LoadedRegistry, run_dir: &Path) -> Result<Manifest> {
258 finalize_at(reg, run_dir, Utc::now())
259}
260
261pub fn finalize_at(
266 reg: &LoadedRegistry,
267 run_dir: &Path,
268 completed_at: DateTime<Utc>,
269) -> Result<Manifest> {
270 let _lock = RootLock::acquire(®.root).context("acquire root lock")?;
271
272 let prepare: PrepareContext = read_json(&run_dir.join(PREPARE_FILE))?;
273 let result: RunResult = read_json(&run_dir.join(RESULT_FILE))?;
274 if prepare.control_id != result.control_id || prepare.run_id != result.run_id {
275 bail!(
276 "result.json mismatches prepare.json (prepare={}/{}, result={}/{})",
277 prepare.control_id,
278 prepare.run_id,
279 result.control_id,
280 result.run_id,
281 );
282 }
283
284 let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
286 let hashed = hasher::hash_tree(run_dir, &exclude)?;
287
288 let mut artifacts: Vec<Artifact> = Vec::new();
289 let mut by_system_artifacts: BTreeMap<String, Vec<Artifact>> = BTreeMap::new();
290
291 for h in &hashed {
292 let art = Artifact {
293 path: h.path.clone(),
294 sha256: h.sha256.clone(),
295 bytes: h.bytes,
296 };
297 if let Some(sys) = h.path.strip_prefix("by-system/") {
298 let name = sys.split('/').next().unwrap_or("").to_string();
300 by_system_artifacts.entry(name).or_default().push(art);
301 } else {
302 artifacts.push(art);
303 }
304 }
305
306 let by_system_blocks: Vec<BySystemBlock> = result
307 .by_system
308 .iter()
309 .map(|sr| BySystemBlock {
310 name: sr.name.clone(),
311 status: sr.status,
312 summary: None,
313 artifacts: by_system_artifacts.remove(&sr.name).unwrap_or_default(),
314 })
315 .collect();
316
317 let mut extra_blocks: Vec<BySystemBlock> = by_system_artifacts
320 .into_iter()
321 .map(|(name, arts)| BySystemBlock {
322 name,
323 status: SystemOutcome::Complete,
324 summary: None,
325 artifacts: arts,
326 })
327 .collect();
328 let mut by_system_blocks = by_system_blocks;
329 by_system_blocks.append(&mut extra_blocks);
330 by_system_blocks.sort_by(|a, b| a.name.cmp(&b.name));
331
332 let prior_run = prior_run_link(®.root, &prepare.control_id, &prepare.run_id)?;
334
335 let control_sha256 = sha256_for_control(®.root, &prepare.control_id)?;
336 let skill_sha256 = sha256_for_skill(®.root, ®.controls[&prepare.control_id].skill)?;
337
338 let manifest = Manifest {
339 schema_version: SCHEMA_VERSION,
340 control_id: prepare.control_id.clone(),
341 run_id: prepare.run_id.clone(),
342 started_at: prepare.started_at,
343 completed_at,
344 operator: prepare.operator.clone(),
345 agent: AgentInfo {
346 model: std::env::var("SECUNIT_AGENT_MODEL").unwrap_or_else(|_| "unknown".into()),
347 skill: reg.controls[&prepare.control_id].skill.clone(),
348 skill_sha256,
349 control_sha256,
350 },
351 registry_git_sha: prepare.registry_git_sha.clone(),
352 scope_layout: prepare.scope_layout,
353 resolved_scope: prepare.resolved_scope.clone(),
354 prior_run,
355 artifacts,
356 by_system: by_system_blocks,
357 status: result.status,
358 failure_reason: None,
359 draft_risks: result.draft_risks.clone(),
360 draft_issues: result.draft_issues.clone(),
361 external_links: result.external_links.clone(),
362 period_id: prepare.period_id.clone(),
363 };
364
365 let bytes = serde_json::to_vec(&manifest)?;
369 atomic_write(&run_dir.join(MANIFEST_FILE), &bytes)?;
370
371 update_state(reg, &manifest)?;
372
373 let pending = run_dir.join(PENDING_SENTINEL);
374 if pending.exists() {
375 fs::remove_file(&pending)?;
376 }
377 Ok(manifest)
378}
379
380#[derive(Debug, Clone)]
382pub struct PendingRun {
383 pub control_id: String,
384 pub run_id: String,
385 pub run_dir: PathBuf,
386}
387
388pub fn list_pending(root: &Path) -> Result<Vec<PendingRun>> {
395 let mut out = Vec::new();
396 let evidence = root.join("evidence");
397 if !evidence.exists() {
398 return Ok(out);
399 }
400 for entry in walkdir::WalkDir::new(&evidence).max_depth(5) {
402 let entry = entry?;
403 if entry.file_name() == PENDING_SENTINEL {
404 let run_dir = entry.path().parent().unwrap().to_path_buf();
405 if run_dir.join(MANIFEST_FILE).exists() {
407 let _ = fs::remove_file(entry.path());
408 continue;
409 }
410 let prepare_path = run_dir.join(PREPARE_FILE);
411 if prepare_path.exists() {
412 let prepare: PrepareContext = read_json(&prepare_path)?;
413 out.push(PendingRun {
414 control_id: prepare.control_id,
415 run_id: prepare.run_id,
416 run_dir,
417 });
418 }
419 }
420 }
421 Ok(out)
422}
423
424fn allocate_run_dir(root: &Path, control_id: &str, today: NaiveDate) -> Result<PathBuf> {
427 let q = quarter_label(today);
428 let base = root
429 .join("evidence")
430 .join(today.year().to_string())
431 .join(&q)
432 .join(control_id);
433 fs::create_dir_all(&base)?;
434 let mut n = 1u32;
435 loop {
436 let id = format!("{}-run-{:03}", today, n);
437 let candidate = base.join(&id);
438 if !candidate.exists() {
439 fs::create_dir_all(&candidate)?;
440 return Ok(candidate);
441 }
442 n += 1;
443 if n > 999 {
444 bail!("run-id counter overflowed for {control_id} on {today}");
445 }
446 }
447}
448
449fn quarter_label(d: NaiveDate) -> String {
450 let q = (d.month() - 1) / 3 + 1;
451 format!("q{q}")
452}
453
454fn run_id_from_dir(dir: &Path) -> Result<String> {
455 Ok(dir
456 .file_name()
457 .ok_or_else(|| anyhow!("run dir has no basename"))?
458 .to_string_lossy()
459 .into_owned())
460}
461
462fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
463 let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
464 Ok(serde_json::from_slice(&bytes)?)
465}
466
467fn prior_run_link(root: &Path, control_id: &str, current_run_id: &str) -> Result<Option<PriorRun>> {
468 let evidence = root.join("evidence");
469 if !evidence.exists() {
470 return Ok(None);
471 }
472 let mut all_manifests: Vec<(String, PathBuf)> = Vec::new();
473 for entry in walkdir::WalkDir::new(&evidence) {
474 let entry = entry?;
475 if entry.file_name() != MANIFEST_FILE {
476 continue;
477 }
478 let dir = entry.path().parent().unwrap();
479 let id = dir
480 .file_name()
481 .and_then(|s| s.to_str())
482 .unwrap_or("")
483 .to_string();
484 let parent_control = dir
486 .parent()
487 .and_then(|p| p.file_name())
488 .and_then(|s| s.to_str())
489 .unwrap_or("");
490 if parent_control != control_id {
491 continue;
492 }
493 all_manifests.push((id, entry.path().to_path_buf()));
494 }
495 all_manifests.sort_by(|a, b| a.0.cmp(&b.0));
496 let prior = all_manifests
497 .into_iter()
498 .rfind(|(id, _)| id.as_str() < current_run_id);
499 match prior {
500 None => Ok(None),
501 Some((id, path)) => {
502 let sha = sha256_file(&path)?;
503 Ok(Some(PriorRun {
504 run_id: id,
505 manifest_sha256: sha,
506 }))
507 }
508 }
509}
510
511fn sha256_for_control(root: &Path, control_id: &str) -> Result<String> {
512 let path = root.join("controls").join(format!("{control_id}.yaml"));
513 sha256_file(&path).with_context(|| format!("hash control {}", path.display()))
514}
515
516fn sha256_for_skill(root: &Path, skill: &str) -> Result<String> {
517 crate::skills::resolve(root, skill)
518 .map(|r| r.sha256)
519 .ok_or_else(|| {
520 anyhow!("skill `{skill}` not found (no local skills/{skill}.md and not bundled)")
521 })
522}
523
524pub fn git_head(root: &Path) -> Result<String> {
533 let repo = gix::open(root).context("open repo")?;
534 let head = repo.head().context("read HEAD")?;
535 let id = head.into_peeled_id().context("peel HEAD to a commit")?;
536 Ok(id.to_hex().to_string())
537}
538
539pub fn git_is_dirty(root: &Path) -> Result<bool> {
543 let repo = gix::open(root).context("open repo")?;
544 repo.is_dirty().context("compute worktree status")
545}
546
547fn update_state(reg: &LoadedRegistry, manifest: &Manifest) -> Result<()> {
548 let path = reg.root.join(STATE_FILE);
549 let mut state: crate::model::State = if path.exists() {
554 let bytes = fs::read(&path)?;
555 serde_json::from_slice(&bytes).with_context(|| {
556 format!(
557 "{} is corrupt; refusing to overwrite — remove it manually to reset state",
558 path.display()
559 )
560 })?
561 } else {
562 crate::model::State::default()
563 };
564
565 let run_date = manifest
572 .run_id
573 .get(0..10)
574 .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
575 let next_due = run_date.and_then(|d| {
576 let lookup_from = d + chrono::Duration::days(1);
577 reg.controls.get(&manifest.control_id).and_then(|ctrl| {
578 crate::registry::resolver::next_due(
579 ctrl,
580 ®.schedule,
581 None,
582 lookup_from,
583 reg.config.weekly_default_weekday,
584 )
585 })
586 });
587
588 state.controls.insert(
589 manifest.control_id.clone(),
590 StateEntry {
591 last_run_id: Some(manifest.run_id.clone()),
592 last_run_path: Some(manifest_relative_path(®.root, manifest)),
593 last_run_at: Some(manifest.completed_at),
594 last_status: match manifest.status {
595 RunOutcome::Complete => RunStatus::Complete,
596 RunOutcome::Partial => RunStatus::InProgress,
597 RunOutcome::Failed => RunStatus::Failed,
598 },
599 next_due,
600 },
601 );
602 state.updated_at = Some(Utc::now());
603 let bytes = serde_json::to_vec_pretty(&state)?;
604 atomic_write(&path, &bytes)?;
605 Ok(())
606}
607
608fn manifest_relative_path(_root: &Path, manifest: &Manifest) -> String {
609 let q = {
611 let date = manifest
613 .run_id
614 .get(0..10)
615 .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
616 date.map(quarter_label).unwrap_or_else(|| "q0".into())
617 };
618 let year = manifest.run_id.get(0..4).unwrap_or("0000");
619 format!(
620 "evidence/{year}/{q}/{cid}/{rid}/",
621 cid = manifest.control_id,
622 rid = manifest.run_id,
623 )
624}
625
626#[allow(dead_code)]
628fn _silence_unused() -> String {
629 sha256_bytes(b"")
630}