Skip to main content

mars_agents/reconcile/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::MarsError;
4use crate::types::{ContentHash, ItemKind};
5
6pub mod fs_ops;
7
8pub use fs_ops::*;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum DestinationState {
12    Empty,
13    File { hash: ContentHash },
14    Directory { hash: ContentHash },
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum DesiredState {
19    CopyFile { source: PathBuf, hash: ContentHash },
20    CopyDir { source: PathBuf, hash: ContentHash },
21    Absent,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ReconcileOutcome {
26    Created,
27    Updated,
28    Removed,
29    Skipped {
30        reason: &'static str,
31    },
32    Conflict {
33        existing: DestinationState,
34        desired: DesiredState,
35    },
36}
37
38/// Scan a destination to determine its current state.
39pub fn scan_destination(path: &Path) -> DestinationState {
40    scan_destination_checked(path).unwrap_or(DestinationState::Empty)
41}
42
43/// Reconcile a single destination path to desired state.
44pub fn reconcile_one(
45    dest: &Path,
46    desired: DesiredState,
47    force: bool,
48) -> Result<ReconcileOutcome, MarsError> {
49    let existing = scan_destination_checked(dest)?;
50
51    match desired {
52        DesiredState::Absent => {
53            if matches!(existing, DestinationState::Empty) {
54                Ok(ReconcileOutcome::Skipped {
55                    reason: "already absent",
56                })
57            } else {
58                safe_remove(dest)?;
59                Ok(ReconcileOutcome::Removed)
60            }
61        }
62        DesiredState::CopyFile { source, hash } => match existing {
63            DestinationState::Empty => {
64                atomic_copy_file(&source, dest)?;
65                Ok(ReconcileOutcome::Created)
66            }
67            DestinationState::File {
68                hash: existing_hash,
69            } if existing_hash == hash => Ok(ReconcileOutcome::Skipped {
70                reason: "already up-to-date",
71            }),
72            existing_state => {
73                if !force {
74                    return Ok(ReconcileOutcome::Conflict {
75                        existing: existing_state,
76                        desired: DesiredState::CopyFile { source, hash },
77                    });
78                }
79                safe_remove(dest)?;
80                atomic_copy_file(&source, dest)?;
81                Ok(ReconcileOutcome::Updated)
82            }
83        },
84        DesiredState::CopyDir { source, hash } => match existing {
85            DestinationState::Empty => {
86                atomic_copy_dir(&source, dest)?;
87                Ok(ReconcileOutcome::Created)
88            }
89            DestinationState::Directory {
90                hash: existing_hash,
91            } if existing_hash == hash => Ok(ReconcileOutcome::Skipped {
92                reason: "already up-to-date",
93            }),
94            existing_state => {
95                if !force {
96                    return Ok(ReconcileOutcome::Conflict {
97                        existing: existing_state,
98                        desired: DesiredState::CopyDir { source, hash },
99                    });
100                }
101                safe_remove(dest)?;
102                atomic_copy_dir(&source, dest)?;
103                Ok(ReconcileOutcome::Updated)
104            }
105        },
106    }
107}
108
109fn scan_destination_checked(path: &Path) -> Result<DestinationState, MarsError> {
110    let metadata = match std::fs::metadata(path) {
111        Ok(metadata) => metadata,
112        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(DestinationState::Empty),
113        Err(e) => return Err(e.into()),
114    };
115
116    if metadata.is_file() {
117        return Ok(DestinationState::File {
118            hash: content_hash(path, ItemKind::Agent)?,
119        });
120    }
121
122    if metadata.is_dir() {
123        return Ok(DestinationState::Directory {
124            hash: content_hash(path, ItemKind::Skill)?,
125        });
126    }
127
128    Ok(DestinationState::Empty)
129}