1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::compose::env_key_looks_secret;
9use crate::error::{DeployError, Result};
10use crate::failure::{classify_deploy_error, ClassifiedDeployOutcome, DeployFailureClass};
11use crate::types::DeployPlan;
12
13pub const DEPLOY_SECRET_PLACEHOLDER_PREFIX: &str = "xbp:deploy:";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HistoryEntry {
19 pub id: String,
20 pub status: String,
21 pub path: String,
22 pub timestamp: DateTime<Utc>,
23 pub env: String,
24 pub target: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub error_code: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub phase: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct DeployHistoryIndex {
35 pub entries: Vec<HistoryEntry>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DeployHistoryRecord {
40 pub id: String,
41 pub timestamp: DateTime<Utc>,
42 pub env: String,
43 pub target: String,
44 pub target_kind: String,
45 pub status: String,
46 pub services: Vec<String>,
47 pub git_sha: Option<String>,
48 pub project_version: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub xbp_version: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub runtime_env_redacted: Option<bool>,
57 pub digests: BTreeMap<String, String>,
58 pub kubernetes_context: Option<String>,
59 pub namespace: Option<String>,
60 pub summary: String,
61 pub error: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub error_code: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub phase: Option<String>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub diagnostics: Vec<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub provider: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub destination: Option<String>,
77 pub plan: DeployPlan,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct DeployHistorySecret {
83 pub name: String,
84 pub value: String,
85 pub hash: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub service: Option<String>,
90}
91
92#[derive(Debug, Clone)]
94pub struct SanitizedHistoryRecord {
95 pub record: DeployHistoryRecord,
96 pub secrets: Vec<DeployHistorySecret>,
97}
98
99pub struct DeployHistoryStore {
100 pub dir: PathBuf,
101}
102
103impl DeployHistoryStore {
104 pub fn new(dir: impl Into<PathBuf>) -> Self {
105 Self { dir: dir.into() }
106 }
107
108 pub fn index_path(&self) -> PathBuf {
109 self.dir.join("index.json")
110 }
111
112 pub fn load_index(&self) -> Result<DeployHistoryIndex> {
113 let path = self.index_path();
114 if !path.exists() {
115 return Ok(DeployHistoryIndex::default());
116 }
117 let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
118 if raw.contains("<<<<<<<") || raw.contains(">>>>>>>") {
120 return self.rebuild_index_from_records();
121 }
122 match parse_index_raw(&raw) {
123 Ok(index) => Ok(index),
124 Err(_) => {
125 self.rebuild_index_from_records()
127 }
128 }
129 }
130
131 pub fn rebuild_index_from_records(&self) -> Result<DeployHistoryIndex> {
133 let mut entries = self.scan_record_entries()?;
134 entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
136 entries.truncate(200);
137 let index = DeployHistoryIndex { entries };
138 self.write_index_atomic(&index)?;
139 Ok(index)
140 }
141
142 pub fn scan_record_entries(&self) -> Result<Vec<HistoryEntry>> {
144 if !self.dir.is_dir() {
145 return Ok(Vec::new());
146 }
147 let mut entries = Vec::new();
148 let rd = std::fs::read_dir(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
149 for ent in rd {
150 let ent = ent.map_err(|e| DeployError::Io(e.to_string()))?;
151 let path = ent.path();
152 let name = match path.file_name().and_then(|n| n.to_str()) {
153 Some(n) => n.to_string(),
154 None => continue,
155 };
156 if name == "index.json" || !name.ends_with(".json") {
157 continue;
158 }
159 if name.contains(".revitalized.") {
160 continue;
161 }
162 let raw = match std::fs::read_to_string(&path) {
163 Ok(r) => r,
164 Err(_) => continue,
165 };
166 let record: DeployHistoryRecord = match serde_json::from_str(&raw) {
167 Ok(r) => r,
168 Err(_) => continue,
169 };
170 entries.push(history_entry_from_record(&record, &name));
171 }
172 Ok(entries)
173 }
174
175 fn write_index_atomic(&self, index: &DeployHistoryIndex) -> Result<()> {
176 std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
177 let index_body =
178 serde_json::to_string_pretty(index).map_err(|e| DeployError::Io(e.to_string()))?;
179 let final_path = self.index_path();
180 let tmp_path = self.dir.join("index.json.tmp");
181 std::fs::write(&tmp_path, index_body).map_err(|e| DeployError::Io(e.to_string()))?;
182 if final_path.exists() {
184 let _ = std::fs::remove_file(&final_path);
185 }
186 std::fs::rename(&tmp_path, &final_path).map_err(|e| DeployError::Io(e.to_string()))?;
187 Ok(())
188 }
189
190 pub fn write_record(&self, record: &DeployHistoryRecord) -> Result<PathBuf> {
191 std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
192 let file_name = format!("{}.json", record.id);
193 let path = self.dir.join(&file_name);
194 let body =
195 serde_json::to_string_pretty(record).map_err(|e| DeployError::Io(e.to_string()))?;
196 let tmp = self.dir.join(format!("{}.json.tmp", record.id));
198 std::fs::write(&tmp, &body).map_err(|e| DeployError::Io(e.to_string()))?;
199 if path.exists() {
200 let _ = std::fs::remove_file(&path);
201 }
202 std::fs::rename(&tmp, &path).map_err(|e| DeployError::Io(e.to_string()))?;
203
204 let mut index = self.load_index().unwrap_or_default();
205 index.entries.retain(|e| e.id != record.id);
206 index
207 .entries
208 .insert(0, history_entry_from_record(record, &file_name));
209 index.entries.truncate(200);
211 self.write_index_atomic(&index)?;
212 Ok(path)
213 }
214
215 pub fn stats(&self, target: &str, env: &str, limit: usize) -> Result<DeployHistoryStats> {
217 let entries = self.list(target, env, limit.max(1))?;
218 let mut by_code: BTreeMap<String, usize> = BTreeMap::new();
219 let mut by_target: BTreeMap<String, usize> = BTreeMap::new();
220 let mut success = 0usize;
221 let mut failed = 0usize;
222 for entry in &entries {
223 *by_target.entry(entry.target.clone()).or_default() += 1;
224 if entry.status == "success" {
225 success += 1;
226 *by_code
227 .entry(DeployFailureClass::Success.as_str().into())
228 .or_default() += 1;
229 continue;
230 }
231 failed += 1;
232 let code = if let Some(c) = &entry.error_code {
233 c.clone()
234 } else if let Ok(Some(rec)) = self.load_record(entry) {
235 rec.error_code
236 .or_else(|| {
237 let c = classify_deploy_error(
238 rec.error.as_deref(),
239 &rec.summary,
240 false,
241 );
242 Some(c.error_code.as_str().into())
243 })
244 .unwrap_or_else(|| DeployFailureClass::Unknown.as_str().into())
245 } else {
246 DeployFailureClass::Unknown.as_str().into()
247 };
248 *by_code.entry(code).or_default() += 1;
249 }
250 Ok(DeployHistoryStats {
251 total: entries.len(),
252 success,
253 failed,
254 by_error_code: by_code,
255 by_target,
256 })
257 }
258
259 pub fn latest_status(&self, target: &str, env: &str) -> Result<Option<HistoryEntry>> {
260 let index = self.load_index()?;
261 Ok(index
262 .entries
263 .into_iter()
264 .find(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env)))
265 }
266
267 pub fn list(&self, target: &str, env: &str, limit: usize) -> Result<Vec<HistoryEntry>> {
268 let index = self.load_index()?;
269 Ok(index
270 .entries
271 .into_iter()
272 .filter(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env))
273 .take(limit)
274 .collect())
275 }
276
277 pub fn load_record(&self, entry: &HistoryEntry) -> Result<Option<DeployHistoryRecord>> {
279 let candidates = [
280 self.dir.join(&entry.path),
281 self.dir.join(format!("{}.json", entry.id)),
282 ];
283 for path in candidates {
284 if !path.is_file() {
285 continue;
286 }
287 let raw =
288 std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
289 let record: DeployHistoryRecord =
290 serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
291 return Ok(Some(record));
292 }
293 Ok(None)
294 }
295
296 pub fn load_record_by_id(&self, id: &str) -> Result<Option<DeployHistoryRecord>> {
298 let id = id.trim().trim_end_matches(".json");
299 if id.is_empty() {
300 return Ok(None);
301 }
302 let path = self.dir.join(format!("{id}.json"));
303 if !path.is_file() {
304 return Ok(None);
305 }
306 let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
307 let record: DeployHistoryRecord =
308 serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
309 Ok(Some(record))
310 }
311
312 pub fn record_path(&self, id: &str) -> PathBuf {
313 let id = id.trim().trim_end_matches(".json");
314 self.dir.join(format!("{id}.json"))
315 }
316}
317
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
319pub struct DeployHistoryStats {
320 pub total: usize,
321 pub success: usize,
322 pub failed: usize,
323 pub by_error_code: BTreeMap<String, usize>,
324 pub by_target: BTreeMap<String, usize>,
325}
326
327fn history_entry_from_record(record: &DeployHistoryRecord, file_name: &str) -> HistoryEntry {
328 HistoryEntry {
329 id: record.id.clone(),
330 status: record.status.clone(),
331 path: file_name.to_string(),
332 timestamp: record.timestamp,
333 env: record.env.clone(),
334 target: record.target.clone(),
335 error_code: record.error_code.clone(),
336 phase: record.phase.clone(),
337 }
338}
339
340pub fn parse_index_raw(raw: &str) -> Result<DeployHistoryIndex> {
342 if raw.contains("<<<<<<<") || raw.contains(">>>>>>>") || raw.contains("=======") {
343 let cleaned: String = raw
345 .lines()
346 .filter(|line| {
347 let t = line.trim_start();
348 !(t.starts_with("<<<<<<<")
349 || t.starts_with(">>>>>>>")
350 || t.starts_with("======="))
351 })
352 .collect::<Vec<_>>()
353 .join("\n");
354 if let Ok(index) = serde_json::from_str::<DeployHistoryIndex>(&cleaned) {
355 return Ok(index);
356 }
357 return Err(DeployError::Io(
358 "deploy history index.json contains merge conflict markers".into(),
359 ));
360 }
361 serde_json::from_str(raw).map_err(|e| DeployError::Io(e.to_string()))
362}
363
364pub fn make_record_id(env: &str, target: &str, ts: DateTime<Utc>) -> String {
365 let safe_target: String = target
366 .chars()
367 .map(|c| {
368 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
369 c
370 } else {
371 '_'
372 }
373 })
374 .collect();
375 format!("{}-{}-{}", ts.format("%Y%m%dT%H%M%SZ"), env, safe_target)
376}
377
378pub fn secret_value_hash(value: &str) -> String {
380 let digest = Sha256::digest(value.as_bytes());
381 digest
382 .iter()
383 .flat_map(|b| [nibble(b >> 4), nibble(b & 0x0f)])
384 .take(12)
385 .collect()
386}
387
388fn nibble(n: u8) -> char {
389 match n {
390 0..=9 => (b'0' + n) as char,
391 10..=15 => (b'a' + (n - 10)) as char,
392 _ => '0',
393 }
394}
395
396pub fn deploy_secret_placeholder(deployment_id: &str, name: &str, hash: &str) -> String {
399 format!("{DEPLOY_SECRET_PLACEHOLDER_PREFIX}{deployment_id}:{name}:{hash}")
400}
401
402pub fn parse_deploy_secret_placeholder(value: &str) -> Option<(&str, &str, &str)> {
404 let rest = value.strip_prefix(DEPLOY_SECRET_PLACEHOLDER_PREFIX)?;
405 let (left, hash) = rest.rsplit_once(':')?;
408 let (deployment_id, name) = left.rsplit_once(':')?;
409 if deployment_id.is_empty() || name.is_empty() || hash.is_empty() {
410 return None;
411 }
412 Some((deployment_id, name, hash))
413}
414
415pub fn runtime_env_value_should_redact(key: &str, value: &str) -> bool {
417 if value.is_empty() {
418 return false;
419 }
420 if value.starts_with(DEPLOY_SECRET_PLACEHOLDER_PREFIX) {
421 return false; }
423 if env_key_looks_secret(key) {
424 return true;
425 }
426 let v = value.trim();
428 if v.contains("://") && v.contains('@') {
429 return true; }
431 if v.contains("://") && v.to_ascii_lowercase().contains("/webhooks/") {
433 return true;
434 }
435 if v.len() >= 24
436 && v.chars()
437 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
438 {
439 return true;
441 }
442 false
443}
444
445pub fn redact_plan_runtime_env(
447 plan: &mut DeployPlan,
448 deployment_id: &str,
449) -> Vec<DeployHistorySecret> {
450 let mut secrets: Vec<DeployHistorySecret> = Vec::new();
451 let mut seen: BTreeMap<String, String> = BTreeMap::new(); for svc in &mut plan.services {
454 let mut next_env = BTreeMap::new();
455 for (key, value) in std::mem::take(&mut svc.runtime_env) {
456 if runtime_env_value_should_redact(&key, &value) {
457 let hash = secret_value_hash(&value);
458 let placeholder = deploy_secret_placeholder(deployment_id, &key, &hash);
459 if let Some(prev_hash) = seen.get(&key) {
460 if prev_hash != &hash {
461 let scoped = format!("{}__{}", svc.name.replace('@', "_"), key);
463 let hash2 = secret_value_hash(&value);
464 let ph = deploy_secret_placeholder(deployment_id, &scoped, &hash2);
465 secrets.push(DeployHistorySecret {
466 name: scoped,
467 value: value.clone(),
468 hash: hash2,
469 service: Some(svc.name.clone()),
470 });
471 next_env.insert(key, ph);
472 continue;
473 }
474 } else {
475 seen.insert(key.clone(), hash.clone());
476 secrets.push(DeployHistorySecret {
477 name: key.clone(),
478 value: value.clone(),
479 hash: hash.clone(),
480 service: Some(svc.name.clone()),
481 });
482 }
483 next_env.insert(key, placeholder);
484 } else {
485 next_env.insert(key, value);
486 }
487 }
488 svc.runtime_env = next_env;
489 }
490 secrets
491}
492
493pub fn revitalize_record_runtime_env(
496 record: &mut DeployHistoryRecord,
497 secrets: &BTreeMap<String, String>,
498) -> usize {
499 let mut filled = 0usize;
500 for svc in &mut record.plan.services {
501 for (_key, value) in svc.runtime_env.iter_mut() {
502 if let Some((dep_id, name, hash)) = parse_deploy_secret_placeholder(value) {
503 if dep_id != record.id {
504 continue;
505 }
506 if let Some(plain) = secrets.get(name) {
507 let actual = secret_value_hash(plain);
508 if actual == hash || hash.is_empty() {
509 *value = plain.clone();
510 filled += 1;
511 }
512 }
513 }
514 }
515 }
516 if filled > 0 {
517 record.runtime_env_redacted = Some(false);
518 }
519 filled
520}
521
522pub fn record_from_plan(
523 plan: &DeployPlan,
524 ok: bool,
525 summary: String,
526 error: Option<String>,
527 kube_context: Option<String>,
528) -> SanitizedHistoryRecord {
529 record_from_plan_with_version(plan, ok, summary, error, kube_context, None)
530}
531
532pub fn record_from_plan_with_version(
533 plan: &DeployPlan,
534 ok: bool,
535 summary: String,
536 error: Option<String>,
537 kube_context: Option<String>,
538 xbp_version: Option<String>,
539) -> SanitizedHistoryRecord {
540 let classified = classify_deploy_error(error.as_deref(), &summary, ok);
541 record_from_plan_with_classification(
542 plan,
543 ok,
544 summary,
545 error,
546 kube_context,
547 xbp_version,
548 classified,
549 )
550}
551
552pub fn record_from_plan_with_classification(
553 plan: &DeployPlan,
554 ok: bool,
555 summary: String,
556 error: Option<String>,
557 kube_context: Option<String>,
558 xbp_version: Option<String>,
559 classified: ClassifiedDeployOutcome,
560) -> SanitizedHistoryRecord {
561 let ts = Utc::now();
562 let id = make_record_id(&plan.env, &plan.target.label(), ts);
563 let mut digests = BTreeMap::new();
564 for svc in &plan.services {
565 if let Some(d) = &svc.digest {
566 digests.insert(svc.name.clone(), d.clone());
567 }
568 }
569
570 let mut plan_for_disk = plan.clone();
571 let secrets = redact_plan_runtime_env(&mut plan_for_disk, &id);
572 let redacted = !secrets.is_empty();
573
574 let primary = plan.services.first();
575 let provider = primary.map(|s| s.provider.clone());
576 let destination = primary.and_then(|s| s.destination.clone());
577
578 let (error_code, phase, diagnostics) = if ok {
579 (
580 Some(DeployFailureClass::Success.as_str().to_string()),
581 None,
582 Vec::new(),
583 )
584 } else {
585 (
586 Some(classified.error_code.as_str().to_string()),
587 Some(classified.phase.as_str().to_string()),
588 classified.diagnostics,
589 )
590 };
591
592 let record = DeployHistoryRecord {
593 id,
594 timestamp: ts,
595 env: plan.env.clone(),
596 target: plan.target.label(),
597 target_kind: plan.target.kind_str().into(),
598 status: if ok {
599 "success".into()
600 } else {
601 "failed".into()
602 },
603 services: plan.order.clone(),
604 git_sha: plan.git_sha.clone(),
605 project_version: Some(plan.project_version.clone()),
606 xbp_version,
607 runtime_env_redacted: if redacted { Some(true) } else { None },
608 digests,
609 kubernetes_context: kube_context,
610 namespace: plan
611 .services
612 .first()
613 .and_then(|s| s.deploy.namespace.clone()),
614 summary,
615 error,
616 error_code,
617 phase,
618 diagnostics,
619 provider,
620 destination,
621 plan: plan_for_disk,
622 };
623
624 SanitizedHistoryRecord { record, secrets }
625}
626#[allow(dead_code)]
628pub fn history_dir(root: &Path, rel: &Path) -> PathBuf {
629 root.join(rel)
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::types::{
636 DeployTarget, K8sPlanView, OciPlan, ServiceDeployPlan, ServicePlan,
637 };
638 use std::collections::BTreeMap;
639
640 fn sample_plan_with_secret() -> DeployPlan {
641 let mut env = BTreeMap::new();
642 env.insert("PORT".into(), "5052".into());
643 env.insert("ATHENA_DEBUG_JWT_SECRET".into(), "super-secret-token-value".into());
644 env.insert(
645 "DATABASE_URL".into(),
646 "postgresql://user:pass@host:5432/db".into(),
647 );
648 DeployPlan {
649 target: DeployTarget::Service("athena".into()),
650 env: "production".into(),
651 project: "athena".into(),
652 project_version: "4.1.0".into(),
653 git_sha: Some("abc".into()),
654 services: vec![ServicePlan {
655 name: "athena".into(),
656 provider: "kubernetes".into(),
657 destination: Some("kubernetes".into()),
658 root_directory: None,
659 version: "4.1.0".into(),
660 image: None,
661 image_ref: None,
662 digest: None,
663 dockerfile: None,
664 build_context: None,
665 platforms: vec![],
666 worker_app: None,
667 rollout: None,
668 runtime_env: env,
669 container_port: Some(5052),
670 config_mounts: vec![],
671 expose: None,
672 deploy: ServiceDeployPlan {
673 namespace: Some("athena".into()),
674 workload: None,
675 service: None,
676 health: vec![],
677 manifest_paths: vec![],
678 crds_path: None,
679 install_path: None,
680 selector: None,
681 actions: vec![],
682 },
683 }],
684 order: vec!["athena@kubernetes".into()],
685 oci_plan: OciPlan::default(),
686 k8s_plan: K8sPlanView {
687 context: None,
688 default_namespace: Some("athena".into()),
689 services: vec![],
690 },
691 hash: "h".into(),
692 }
693 }
694
695 #[test]
696 fn redacts_discord_webhook_urls_even_without_secret_key_suffix() {
697 assert!(runtime_env_value_should_redact(
699 "DISCORD_WEBHOOK_URL",
700 "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz",
701 ));
702 assert!(runtime_env_value_should_redact(
703 "NOTIFY_HOOK",
704 "https://discordapp.com/api/webhooks/1/token-here",
705 ));
706 assert!(!runtime_env_value_should_redact(
708 "DISCORD_WEBHOOK_URL",
709 "xbp:deploy:id:DISCORD_WEBHOOK_URL:abcdef012345",
710 ));
711 }
712
713 #[test]
714 fn redacts_secrets_and_revitalizes() {
715 let plan = sample_plan_with_secret();
716 let sanitized = record_from_plan_with_version(
717 &plan,
718 true,
719 "ok".into(),
720 None,
721 None,
722 Some("10.51.1".into()),
723 );
724 assert_eq!(sanitized.record.xbp_version.as_deref(), Some("10.51.1"));
725 assert_eq!(sanitized.record.runtime_env_redacted, Some(true));
726 assert!(sanitized.secrets.len() >= 2);
727
728 let env = &sanitized.record.plan.services[0].runtime_env;
729 assert_eq!(env.get("PORT").map(String::as_str), Some("5052"));
730 let secret_ph = env.get("ATHENA_DEBUG_JWT_SECRET").unwrap();
731 assert!(secret_ph.starts_with("xbp:deploy:"));
732 assert!(parse_deploy_secret_placeholder(secret_ph).is_some());
733
734 let mut secrets_map = BTreeMap::new();
735 for s in &sanitized.secrets {
736 secrets_map.insert(s.name.clone(), s.value.clone());
737 }
738 let mut restored = sanitized.record.clone();
739 let n = revitalize_record_runtime_env(&mut restored, &secrets_map);
740 assert!(n >= 2);
741 assert_eq!(
742 restored.plan.services[0]
743 .runtime_env
744 .get("ATHENA_DEBUG_JWT_SECRET")
745 .map(String::as_str),
746 Some("super-secret-token-value")
747 );
748 }
749
750 #[test]
751 fn placeholder_roundtrip() {
752 let ph = deploy_secret_placeholder("20260722T052353Z-production-athena", "FOO", "abc123def456");
753 let (id, name, hash) = parse_deploy_secret_placeholder(&ph).unwrap();
754 assert_eq!(id, "20260722T052353Z-production-athena");
755 assert_eq!(name, "FOO");
756 assert_eq!(hash, "abc123def456");
757 }
758
759 #[test]
760 fn classifies_on_record_write_path() {
761 let plan = sample_plan_with_secret();
762 let sanitized = record_from_plan_with_version(
763 &plan,
764 false,
765 "failed".into(),
766 Some("OpenNext WSL deploy failed with status exit code: 1.".into()),
767 None,
768 Some("10.54.0".into()),
769 );
770 assert_eq!(
771 sanitized.record.error_code.as_deref(),
772 Some("opennext_wsl_failed")
773 );
774 assert_eq!(sanitized.record.phase.as_deref(), Some("opennext"));
775 assert_eq!(sanitized.record.provider.as_deref(), Some("kubernetes"));
776 assert!(!sanitized.record.diagnostics.is_empty());
777 }
778
779 #[test]
780 fn index_salvages_merge_conflict_markers() {
781 let dir = std::env::temp_dir().join(format!(
782 "xbp-deploy-history-test-{}",
783 std::process::id()
784 ));
785 let _ = std::fs::remove_dir_all(&dir);
786 std::fs::create_dir_all(&dir).unwrap();
787 let store = DeployHistoryStore::new(&dir);
788
789 let plan = sample_plan_with_secret();
790 let sanitized = record_from_plan(&plan, true, "deploy succeeded".into(), None, None);
791 store.write_record(&sanitized.record).unwrap();
792
793 let corrupt = r#"{
795 "entries": [
796<<<<<<< Updated upstream
797=======
798 {
799 "id": "stale",
800 "status": "failed",
801 "path": "stale.json",
802 "timestamp": "2026-07-22T13:45:02.303897600Z",
803 "env": "production",
804 "target": "next-heroui-example"
805 },
806>>>>>>> Stashed changes
807 {
808 "id": "keep-me",
809 "status": "success",
810 "path": "keep-me.json",
811 "timestamp": "2026-07-22T05:23:53.154691300Z",
812 "env": "production",
813 "target": "athena"
814 }
815 ]
816}
817"#;
818 std::fs::write(dir.join("index.json"), corrupt).unwrap();
819
820 let index = store.load_index().unwrap();
822 assert!(
823 index.entries.iter().any(|e| e.id == sanitized.record.id),
824 "rebuilt index should include written record"
825 );
826 assert!(
827 !index.entries.iter().any(|e| e.id == "stale"),
828 "stale conflict-only entry without a file should not appear after rebuild"
829 );
830
831 let _ = std::fs::remove_dir_all(&dir);
832 }
833
834 #[test]
835 fn parse_index_strips_conflict_markers_when_json_salvageable() {
836 let raw = r#"{
837 "entries": [
838<<<<<<< Updated upstream
839=======
840 {
841 "id": "a",
842 "status": "failed",
843 "path": "a.json",
844 "timestamp": "2026-07-22T13:45:02.303897600Z",
845 "env": "production",
846 "target": "x"
847 }
848>>>>>>> Stashed changes
849 ]
850}
851"#;
852 let index = parse_index_raw(raw).unwrap();
854 assert_eq!(index.entries.len(), 1);
855 assert_eq!(index.entries[0].id, "a");
856 }
857}