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    Symlink { target: PathBuf },
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum DesiredState {
20    CopyFile { source: PathBuf, hash: ContentHash },
21    CopyDir { source: PathBuf, hash: ContentHash },
22    Symlink { target: PathBuf },
23    Absent,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ReconcileOutcome {
28    Created,
29    Updated,
30    Removed,
31    Skipped {
32        reason: &'static str,
33    },
34    Conflict {
35        existing: DestinationState,
36        desired: DesiredState,
37    },
38}
39
40/// Scan a destination to determine its current state.
41pub fn scan_destination(path: &Path) -> DestinationState {
42    scan_destination_checked(path).unwrap_or(DestinationState::Empty)
43}
44
45/// Reconcile a single destination path to desired state.
46pub fn reconcile_one(
47    dest: &Path,
48    desired: DesiredState,
49    force: bool,
50) -> Result<ReconcileOutcome, MarsError> {
51    let existing = scan_destination_checked(dest)?;
52
53    match desired {
54        DesiredState::Absent => {
55            if matches!(existing, DestinationState::Empty) {
56                Ok(ReconcileOutcome::Skipped {
57                    reason: "already absent",
58                })
59            } else {
60                safe_remove(dest)?;
61                Ok(ReconcileOutcome::Removed)
62            }
63        }
64        DesiredState::CopyFile { source, hash } => match existing {
65            DestinationState::Empty => {
66                atomic_copy_file(&source, dest)?;
67                Ok(ReconcileOutcome::Created)
68            }
69            DestinationState::File {
70                hash: existing_hash,
71            } if existing_hash == hash => Ok(ReconcileOutcome::Skipped {
72                reason: "already up-to-date",
73            }),
74            DestinationState::Symlink { .. } => {
75                safe_remove(dest)?;
76                atomic_copy_file(&source, dest)?;
77                Ok(ReconcileOutcome::Updated)
78            }
79            existing_state => {
80                if !force {
81                    return Ok(ReconcileOutcome::Conflict {
82                        existing: existing_state,
83                        desired: DesiredState::CopyFile { source, hash },
84                    });
85                }
86                safe_remove(dest)?;
87                atomic_copy_file(&source, dest)?;
88                Ok(ReconcileOutcome::Updated)
89            }
90        },
91        DesiredState::CopyDir { source, hash } => match existing {
92            DestinationState::Empty => {
93                atomic_copy_dir(&source, dest)?;
94                Ok(ReconcileOutcome::Created)
95            }
96            DestinationState::Directory {
97                hash: existing_hash,
98            } if existing_hash == hash => Ok(ReconcileOutcome::Skipped {
99                reason: "already up-to-date",
100            }),
101            DestinationState::Symlink { .. } => {
102                safe_remove(dest)?;
103                atomic_copy_dir(&source, dest)?;
104                Ok(ReconcileOutcome::Updated)
105            }
106            existing_state => {
107                if !force {
108                    return Ok(ReconcileOutcome::Conflict {
109                        existing: existing_state,
110                        desired: DesiredState::CopyDir { source, hash },
111                    });
112                }
113                safe_remove(dest)?;
114                atomic_copy_dir(&source, dest)?;
115                Ok(ReconcileOutcome::Updated)
116            }
117        },
118        DesiredState::Symlink { target } => match existing {
119            DestinationState::Symlink {
120                target: existing_target,
121            } if existing_target == target => Ok(ReconcileOutcome::Skipped {
122                reason: "already symlinked",
123            }),
124            DestinationState::Empty => {
125                atomic_symlink(dest, &target)?;
126                Ok(ReconcileOutcome::Created)
127            }
128            existing_state => {
129                if !force {
130                    return Ok(ReconcileOutcome::Conflict {
131                        existing: existing_state,
132                        desired: DesiredState::Symlink { target },
133                    });
134                }
135                safe_remove(dest)?;
136                atomic_symlink(dest, &target)?;
137                Ok(ReconcileOutcome::Updated)
138            }
139        },
140    }
141}
142
143fn scan_destination_checked(path: &Path) -> Result<DestinationState, MarsError> {
144    let metadata = match std::fs::symlink_metadata(path) {
145        Ok(metadata) => metadata,
146        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(DestinationState::Empty),
147        Err(e) => return Err(e.into()),
148    };
149
150    if metadata.file_type().is_symlink() {
151        let target = path.read_link()?;
152        return Ok(DestinationState::Symlink { target });
153    }
154
155    if metadata.is_file() {
156        return Ok(DestinationState::File {
157            hash: content_hash(path, ItemKind::Agent)?,
158        });
159    }
160
161    if metadata.is_dir() {
162        return Ok(DestinationState::Directory {
163            hash: content_hash(path, ItemKind::Skill)?,
164        });
165    }
166
167    Ok(DestinationState::Empty)
168}