Skip to content

Commit 0fa07cd

Browse files
committed
feat(core): filesystem operation layer with dry-run support
1 parent d07d6c4 commit 0fa07cd

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

core/src/resolver/fs_ops.rs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
use std::{
2+
fmt,
3+
path::{Path, PathBuf},
4+
};
5+
6+
use super::model::Action;
7+
8+
pub fn execute(link: &Path, action: &Action, dry_run: bool) -> Result<(), FsError> {
9+
match action {
10+
Action::Skip => Ok(()),
11+
Action::Remove => {
12+
if dry_run {
13+
return Ok(());
14+
}
15+
std::fs::remove_file(link).map_err(|source| FsError::RemoveFailed {
16+
path: link.to_path_buf(),
17+
source,
18+
})
19+
}
20+
Action::Relink(target) => {
21+
if dry_run {
22+
return Ok(());
23+
}
24+
std::fs::remove_file(link).map_err(|source| FsError::RemoveFailed {
25+
path: link.to_path_buf(),
26+
source,
27+
})?;
28+
std::os::unix::fs::symlink(target, link).map_err(|source| FsError::SymlinkFailed {
29+
link: link.to_path_buf(),
30+
target: target.clone(),
31+
source,
32+
})
33+
}
34+
}
35+
}
36+
37+
impl fmt::Display for FsError {
38+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39+
match self {
40+
Self::RemoveFailed { path, source } => {
41+
write!(f, "failed to remove {}: {source}", path.display())
42+
}
43+
Self::SymlinkFailed {
44+
link,
45+
target,
46+
source,
47+
} => {
48+
write!(
49+
f,
50+
"failed to symlink {} -> {}: {source}",
51+
link.display(),
52+
target.display()
53+
)
54+
}
55+
}
56+
}
57+
}
58+
59+
#[derive(Debug)]
60+
pub enum FsError {
61+
RemoveFailed {
62+
path: PathBuf,
63+
source: std::io::Error,
64+
},
65+
SymlinkFailed {
66+
link: PathBuf,
67+
target: PathBuf,
68+
source: std::io::Error,
69+
},
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
use std::{fs, os::unix::fs::symlink};
76+
use tempfile::TempDir;
77+
78+
#[test]
79+
fn relink_replaces_symlink() {
80+
let temp = TempDir::new().unwrap();
81+
let target = temp.path().join("new_target.txt");
82+
fs::write(&target, b"hello").unwrap();
83+
let link = temp.path().join("my_link");
84+
symlink("/nonexistent", &link).unwrap();
85+
86+
execute(&link, &Action::Relink(target.clone()), false).unwrap();
87+
88+
let resolved = fs::read_link(&link).unwrap();
89+
assert_eq!(resolved, target);
90+
}
91+
92+
#[test]
93+
fn remove_deletes_symlink() {
94+
let temp = TempDir::new().unwrap();
95+
let link = temp.path().join("my_link");
96+
symlink("/nonexistent", &link).unwrap();
97+
assert!(link.symlink_metadata().is_ok());
98+
99+
execute(&link, &Action::Remove, false).unwrap();
100+
101+
assert!(link.symlink_metadata().is_err());
102+
}
103+
104+
#[test]
105+
fn skip_is_noop() {
106+
let temp = TempDir::new().unwrap();
107+
let link = temp.path().join("my_link");
108+
symlink("/nonexistent", &link).unwrap();
109+
110+
execute(&link, &Action::Skip, false).unwrap();
111+
112+
assert!(link.symlink_metadata().is_ok());
113+
}
114+
115+
#[test]
116+
fn dry_run_relink_does_not_modify() {
117+
let temp = TempDir::new().unwrap();
118+
let link = temp.path().join("my_link");
119+
symlink("/nonexistent", &link).unwrap();
120+
let target = temp.path().join("new_target.txt");
121+
fs::write(&target, b"hello").unwrap();
122+
123+
execute(&link, &Action::Relink(target), true).unwrap();
124+
125+
let still_broken = fs::read_link(&link).unwrap();
126+
assert_eq!(still_broken, PathBuf::from("/nonexistent"));
127+
}
128+
129+
#[test]
130+
fn dry_run_remove_does_not_modify() {
131+
let temp = TempDir::new().unwrap();
132+
let link = temp.path().join("my_link");
133+
symlink("/nonexistent", &link).unwrap();
134+
135+
execute(&link, &Action::Remove, true).unwrap();
136+
137+
assert!(link.symlink_metadata().is_ok());
138+
}
139+
140+
#[test]
141+
fn remove_nonexistent_fails() {
142+
let temp = TempDir::new().unwrap();
143+
let link = temp.path().join("no_such_link");
144+
145+
let result = execute(&link, &Action::Remove, false);
146+
assert!(result.is_err());
147+
}
148+
149+
#[test]
150+
fn error_display_includes_path() {
151+
let err = FsError::RemoveFailed {
152+
path: "/some/link".into(),
153+
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
154+
};
155+
let msg = err.to_string();
156+
assert!(msg.contains("/some/link"));
157+
assert!(msg.contains("not found"));
158+
}
159+
160+
#[test]
161+
fn error_display_symlink_includes_both_paths() {
162+
let err = FsError::SymlinkFailed {
163+
link: "/some/link".into(),
164+
target: "/some/target".into(),
165+
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
166+
};
167+
let msg = err.to_string();
168+
assert!(msg.contains("/some/link"));
169+
assert!(msg.contains("/some/target"));
170+
assert!(msg.contains("denied"));
171+
}
172+
}

core/src/resolver/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
pub mod action;
22
pub mod display;
3+
pub mod fs_ops;
34
pub mod input;
45
pub mod model;
56

67
pub use action::{Resolved, resolve, resolve_custom};
78
pub use display::{format_actions, format_candidates, format_header, present};
9+
pub use fs_ops::{FsError, execute};
810
pub use input::{ParseError, ParsedInput, parse_choice};
911
pub use model::{Action, RepairCase, Resolution, Summary};

0 commit comments

Comments
 (0)