mars_agents/reconcile/
mod.rs1use 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
40pub fn scan_destination(path: &Path) -> DestinationState {
42 scan_destination_checked(path).unwrap_or(DestinationState::Empty)
43}
44
45pub 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}