Files
notfiles/crates/notfiles/src/status.rs
Joseph O'Brien 6427d188e6
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
feat(notfiles): hexagonal architecture refactor with adapters, integration tests, and notsecrets cleanup
- Extract FileStore port and InMemoryFileStore/FileStoreImpl/Reporter adapters
- Add Reporter trait to notcore with TerminalReporter and JsonReporter
- Refactor linker, package, status modules to use FileStore port
- Add integration test suite with dotfiles fixtures
- Add cross-crate integration tests
- Remove ssh_rsa identity/recipient (unsupported)
- Add rustqual.toml, .sccignore, notfiles.toml config files
- Update CLAUDE.md, README.md, AGENTS.md with current architecture
2026-07-08 13:07:42 -04:00

276 lines
8.4 KiB
Rust

use std::path::{Path, PathBuf};
use serde_json::json;
use crate::linker::State;
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
use notcore::{Config, Method, expand_tilde};
#[derive(Debug, PartialEq)]
pub enum FileStatus {
Linked,
Copied,
Missing,
Conflict,
Orphan,
}
impl std::fmt::Display for FileStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileStatus::Linked => write!(f, "\x1b[32mlinked\x1b[0m"),
FileStatus::Copied => write!(f, "\x1b[32mcopied\x1b[0m"),
FileStatus::Missing => write!(f, "\x1b[31mmissing\x1b[0m"),
FileStatus::Conflict => write!(f, "\x1b[33mconflict\x1b[0m"),
FileStatus::Orphan => write!(f, "\x1b[35morphan\x1b[0m"),
}
}
}
pub struct StatusEntry {
pub source_display: String,
pub target: PathBuf,
pub status: FileStatus,
}
pub fn package_status(
dotfiles_dir: &Path,
config: &Config,
state: &State,
package: &str,
fs: &dyn FileStore,
) -> Vec<StatusEntry> {
let mut results = Vec::new();
let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package);
let target_base = match expand_tilde(config.target_for(package)) {
Ok(p) => p,
Err(_) => return results,
};
// Check files that should exist
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
for relative in &files {
let source = package_dir.join(relative);
let target = target_base.join(relative);
let source_display = format!("{package}/{}", relative.display());
let status = match method {
Method::Symlink => {
if let Ok(link_target) = fs.read_link(&target) {
if link_target == source {
FileStatus::Linked
} else {
FileStatus::Conflict
}
} else if fs.exists(&target) {
FileStatus::Conflict
} else {
FileStatus::Missing
}
}
Method::Copy => {
let tracked = state.entries.iter().any(|e| {
e.package == package
&& e.target == target.to_string_lossy().as_ref()
&& e.source == source.to_string_lossy().as_ref()
});
let content_matches = match (fs.read(&source), fs.read(&target)) {
(Ok(source_bytes), Ok(target_bytes)) => source_bytes == target_bytes,
_ => false,
};
if tracked && content_matches {
FileStatus::Copied
} else if fs.exists(&target) {
FileStatus::Conflict
} else {
FileStatus::Missing
}
}
};
results.push(StatusEntry {
source_display,
target,
status,
});
}
}
// Check for orphans: entries in state that no longer have a source file
for entry in state.entries_for_package(package) {
let source = PathBuf::from(&entry.source);
if !fs.exists(&source) {
let target = PathBuf::from(&entry.target);
// Only add if we didn't already report this target
if !results.iter().any(|r| r.target == target) {
results.push(StatusEntry {
source_display: format!(
"{package}/{}",
source
.strip_prefix(&package_dir)
.unwrap_or(&source)
.display()
),
target,
status: FileStatus::Orphan,
});
}
}
}
results
}
#[derive(Debug, PartialEq)]
pub enum DiffKind {
Identical,
Modified,
SourceOnly,
TargetOnly,
}
pub struct DiffEntry {
pub source_display: String,
pub target: PathBuf,
pub kind: DiffKind,
}
pub fn diff_package(
dotfiles_dir: &Path,
config: &Config,
state: &State,
package: &str,
fs: &dyn FileStore,
) -> Vec<DiffEntry> {
let mut results = Vec::new();
let method = config.method_for(package);
if method != Method::Copy {
return results;
}
let package_dir = dotfiles_dir.join(package);
let target_base = match expand_tilde(config.target_for(package)) {
Ok(p) => p,
Err(_) => return results,
};
// Check files tracked in state for this package
for entry in state.entries_for_package(package) {
let source = PathBuf::from(&entry.source);
let target = PathBuf::from(&entry.target);
let source_display = format!(
"{package}/{}",
source
.strip_prefix(&package_dir)
.unwrap_or(&source)
.display()
);
let kind = match (fs.read(&source), fs.read(&target)) {
(Ok(src), Ok(tgt)) => {
if src == tgt {
DiffKind::Identical
} else {
DiffKind::Modified
}
}
(Ok(_), Err(_)) => DiffKind::SourceOnly,
(Err(_), Ok(_)) => DiffKind::TargetOnly,
(Err(_), Err(_)) => continue,
};
results.push(DiffEntry {
source_display,
target,
kind,
});
}
// Also check untracked files from the package dir
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
for relative in &files {
let source = package_dir.join(relative);
let target = target_base.join(relative);
// Skip if already covered by state entries
if results.iter().any(|r| r.target == target) {
continue;
}
let source_display = format!("{package}/{}", relative.display());
let kind = if !fs.exists(&target) {
DiffKind::SourceOnly
} else {
match (fs.read(&source), fs.read(&target)) {
(Ok(src), Ok(tgt)) if src == tgt => DiffKind::Identical,
(Ok(_), Ok(_)) => DiffKind::Modified,
_ => continue,
}
};
results.push(DiffEntry {
source_display,
target,
kind,
});
}
}
results
}
pub fn print_diff(package: &str, entries: &[DiffEntry]) {
let non_identical: Vec<_> = entries
.iter()
.filter(|e| e.kind != DiffKind::Identical)
.collect();
if non_identical.is_empty() {
println!(" {package}: all copies identical");
return;
}
println!(" \x1b[1m{package}\x1b[0m:");
for entry in &non_identical {
let label = match entry.kind {
DiffKind::Modified => "\x1b[33mmodified\x1b[0m",
DiffKind::SourceOnly => "\x1b[36msource only\x1b[0m",
DiffKind::TargetOnly => "\x1b[35mtarget only\x1b[0m",
DiffKind::Identical => unreachable!(),
};
println!(
" {label} {} -> {}",
entry.source_display,
entry.target.display()
);
}
}
pub fn print_status(package: &str, entries: &[StatusEntry]) {
if entries.is_empty() {
println!(" {package}: (empty)");
return;
}
println!(" \x1b[1m{package}\x1b[0m:");
for entry in entries {
println!(
" {} {} -> {}",
entry.status,
entry.source_display,
entry.target.display()
);
}
}
pub fn print_status_json(package: &str, entries: &[StatusEntry]) {
let items: Vec<_> = entries
.iter()
.map(|e| {
json!({
"source": e.source_display,
"target": e.target.to_string_lossy(),
"status": format!("{:?}", e.status).to_lowercase(),
})
})
.collect();
let obj = json!({"package": package, "entries": items});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
}