Skip to main content

skillfile_core/
conflict.rs

1use std::path::Path;
2
3use crate::error::SkillfileError;
4use crate::models::ConflictState;
5
6pub const CONFLICT_FILE: &str = ".skillfile/conflict";
7
8/// Read conflict state from `.skillfile/conflict`. Returns `None` if no conflict file exists.
9pub fn read_conflict(repo_root: &Path) -> Result<Option<ConflictState>, SkillfileError> {
10    let p = repo_root.join(CONFLICT_FILE);
11    if !p.exists() {
12        return Ok(None);
13    }
14    let text = std::fs::read_to_string(&p)?;
15    let state: ConflictState = serde_json::from_str(&text)
16        .map_err(|e| SkillfileError::Manifest(format!("invalid conflict file: {e}")))?;
17    Ok(Some(state))
18}
19
20pub fn write_conflict(repo_root: &Path, state: &ConflictState) -> Result<(), SkillfileError> {
21    let p = repo_root.join(CONFLICT_FILE);
22    if let Some(parent) = p.parent() {
23        std::fs::create_dir_all(parent)?;
24    }
25    let json = serde_json::to_string_pretty(state)
26        .map_err(|e| SkillfileError::Manifest(format!("failed to serialize conflict: {e}")))?;
27    std::fs::write(&p, format!("{json}\n"))?;
28    Ok(())
29}
30
31/// Remove the conflict file. No-op if it doesn't exist.
32pub fn clear_conflict(repo_root: &Path) -> Result<(), SkillfileError> {
33    let p = repo_root.join(CONFLICT_FILE);
34    if p.exists() {
35        std::fs::remove_file(&p)?;
36    }
37    Ok(())
38}
39
40#[must_use]
41pub fn has_conflict(repo_root: &Path) -> bool {
42    repo_root.join(CONFLICT_FILE).exists()
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use crate::models::EntityType;
49
50    fn make_state() -> ConflictState {
51        ConflictState {
52            entry: "foo".into(),
53            entity_type: EntityType::Agent,
54            old_sha: "a".repeat(40),
55            new_sha: "b".repeat(40),
56        }
57    }
58
59    // -------------------------------------------------------------------
60    // read_conflict
61    // -------------------------------------------------------------------
62
63    #[test]
64    fn read_missing_returns_none() {
65        let dir = tempfile::tempdir().unwrap();
66        assert!(read_conflict(dir.path()).unwrap().is_none());
67    }
68
69    #[test]
70    fn write_then_read_roundtrip() {
71        let dir = tempfile::tempdir().unwrap();
72        let state = make_state();
73        write_conflict(dir.path(), &state).unwrap();
74        assert_eq!(read_conflict(dir.path()).unwrap(), Some(state));
75    }
76
77    // -------------------------------------------------------------------
78    // write_conflict
79    // -------------------------------------------------------------------
80
81    #[test]
82    fn write_produces_valid_json_structure() {
83        let dir = tempfile::tempdir().unwrap();
84        let state = ConflictState {
85            entry: "bar".into(),
86            entity_type: EntityType::Skill,
87            ..make_state()
88        };
89        write_conflict(dir.path(), &state).unwrap();
90        let data: serde_json::Value =
91            serde_json::from_str(&std::fs::read_to_string(dir.path().join(CONFLICT_FILE)).unwrap())
92                .unwrap();
93        assert_eq!(data["entry"], "bar");
94        assert_eq!(data["entity_type"], "skill");
95        assert_eq!(data["old_sha"], "a".repeat(40));
96        assert_eq!(data["new_sha"], "b".repeat(40));
97    }
98
99    #[test]
100    fn write_creates_file() {
101        let dir = tempfile::tempdir().unwrap();
102        write_conflict(dir.path(), &make_state()).unwrap();
103        assert!(dir.path().join(CONFLICT_FILE).exists());
104    }
105
106    // -------------------------------------------------------------------
107    // has_conflict
108    // -------------------------------------------------------------------
109
110    #[test]
111    fn has_conflict_false_when_missing() {
112        let dir = tempfile::tempdir().unwrap();
113        assert!(!has_conflict(dir.path()));
114    }
115
116    #[test]
117    fn has_conflict_true_after_write() {
118        let dir = tempfile::tempdir().unwrap();
119        write_conflict(dir.path(), &make_state()).unwrap();
120        assert!(has_conflict(dir.path()));
121    }
122
123    // -------------------------------------------------------------------
124    // clear_conflict
125    // -------------------------------------------------------------------
126
127    #[test]
128    fn clear_removes_file() {
129        let dir = tempfile::tempdir().unwrap();
130        write_conflict(dir.path(), &make_state()).unwrap();
131        clear_conflict(dir.path()).unwrap();
132        assert!(!has_conflict(dir.path()));
133        assert!(!dir.path().join(CONFLICT_FILE).exists());
134    }
135
136    #[test]
137    fn clear_noop_when_missing() {
138        let dir = tempfile::tempdir().unwrap();
139        clear_conflict(dir.path()).unwrap(); // must not panic
140    }
141}