use std::path::{Path, PathBuf};
use crate::FileOperationKind;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileOperationSummary {
pub changed: usize,
pub skipped: usize,
pub deleted: usize,
pub warnings: usize,
pub metadata_changed: usize,
pub expanded: bool,
pub skip_reason: Option<String>,
}
impl FileOperationSummary {
#[must_use]
pub const fn decision_count(&self) -> usize {
self.changed + self.skipped + self.deleted
}
#[must_use]
pub fn message(
&self,
operation: FileOperationKind,
source: &Path,
target: &Path,
dry_run: bool,
) -> String {
format_file_operation_summary(operation, source, target, self, dry_run)
}
fn count_details(&self, dry_run: bool) -> Vec<String> {
let mut details = Vec::new();
if self.changed > 0 {
details.push(count_detail(
self.changed,
if dry_run { "change" } else { "changed" },
if dry_run { "changes" } else { "changed" },
));
}
if self.skipped > 0 {
details.push(count_detail(
self.skipped,
if dry_run { "skip" } else { "skipped" },
if dry_run { "skips" } else { "skipped" },
));
}
if self.deleted > 0 {
details.push(count_detail(
self.deleted,
if dry_run { "delete" } else { "deleted" },
if dry_run { "deletes" } else { "deleted" },
));
}
details
}
}
fn count_detail(count: usize, singular: &str, plural: &str) -> String {
let noun = if count == 1 { singular } else { plural };
format!("{count} {noun}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputEvent {
IgnoredInitScript {
path: PathBuf,
},
WouldRunInitScript {
path: PathBuf,
root_path: PathBuf,
},
RunInitScript {
path: PathBuf,
},
NoConfigDetected,
RootWorktreeDetected,
ConfigDetected {
path: PathBuf,
},
FileOperationPlanningStarted {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
},
FileOperationPlanningFinished {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
action_count: usize,
},
FileOperationExecutionStarted {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
action_count: usize,
},
FileOperationActionAdvanced {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
},
FileOperationFinished {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
summary: FileOperationSummary,
dry_run: bool,
},
FileApplied {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
},
FileWouldApply {
operation: FileOperationKind,
source: PathBuf,
target: PathBuf,
},
FileMetadataApplied {
source: PathBuf,
target: PathBuf,
},
FileMetadataWouldApply {
source: PathBuf,
target: PathBuf,
},
FileSkipped {
operation: FileOperationKind,
target: PathBuf,
reason: String,
},
FileWouldSkip {
operation: FileOperationKind,
target: PathBuf,
reason: String,
},
FileDeleted {
path: PathBuf,
},
FileWouldDelete {
path: PathBuf,
},
FileWarning {
path: PathBuf,
reason: String,
},
OwnershipWarning {
path: PathBuf,
reason: String,
},
CommandStarted {
label: String,
},
CommandWouldRun {
label: String,
},
CommandAllowedFailure {
label: String,
reason: String,
},
InitCreated {
path: PathBuf,
},
}
impl OutputEvent {
#[must_use]
pub fn message(&self) -> String {
match self {
Self::IgnoredInitScript { path } => {
format!("treeboot: ignore {}; not executable", path.display())
}
Self::WouldRunInitScript { path, root_path } => format!(
"treeboot: would run {} {}",
path.display(),
root_path.display()
),
Self::RunInitScript { path } => {
format!("treeboot: run {}", path.display())
}
Self::NoConfigDetected => "treeboot: no config detected".to_owned(),
Self::RootWorktreeDetected => "treeboot: This is not a work tree".to_owned(),
Self::ConfigDetected { path } => {
format!("treeboot: config detected {}", path.display())
}
Self::FileOperationPlanningStarted { .. }
| Self::FileOperationPlanningFinished { .. }
| Self::FileOperationExecutionStarted { .. }
| Self::FileOperationActionAdvanced { .. } => String::new(),
Self::FileOperationFinished {
operation,
source,
target,
summary,
dry_run,
} => summary.message(*operation, source, target, *dry_run),
Self::FileApplied {
operation,
source,
target,
} => format!(
"treeboot: {} {} -> {}",
operation.as_str(),
source.display(),
target.display()
),
Self::FileWouldApply {
operation,
source,
target,
} => format!(
"treeboot: would {} {} -> {}",
operation.as_str(),
source.display(),
target.display()
),
Self::FileMetadataApplied { source, target } => format!(
"treeboot: sync metadata {} -> {}",
source.display(),
target.display()
),
Self::FileMetadataWouldApply { source, target } => format!(
"treeboot: would sync metadata {} -> {}",
source.display(),
target.display()
),
Self::FileSkipped {
operation,
target,
reason,
} => format!(
"treeboot: skip {} {}; {}",
operation.as_str(),
target.display(),
reason
),
Self::FileWouldSkip {
operation,
target,
reason,
} => format!(
"treeboot: would skip {} {}; {}",
operation.as_str(),
target.display(),
reason
),
Self::FileDeleted { path } => {
format!("treeboot: delete {}", path.display())
}
Self::FileWouldDelete { path } => {
format!("treeboot: would delete {}", path.display())
}
Self::FileWarning { path, reason } => {
format!("treeboot: warning: {} {}", path.display(), reason)
}
Self::OwnershipWarning { path, reason } => format!(
"treeboot: warning: could not preserve ownership {}: {}",
path.display(),
reason
),
Self::CommandStarted { label } => {
format!("treeboot: run {label}")
}
Self::CommandWouldRun { label } => {
format!("treeboot: would run {label}")
}
Self::CommandAllowedFailure { label, reason } => {
format!("treeboot: warning: command {label} {reason}")
}
Self::InitCreated { path } => {
format!("treeboot: created {}", path.display())
}
}
}
}
fn format_file_operation_summary(
operation: FileOperationKind,
source: &Path,
target: &Path,
summary: &FileOperationSummary,
dry_run: bool,
) -> String {
if summary.decision_count() == 1 {
if summary.changed == 1 {
if summary.metadata_changed == 1 {
if dry_run {
return format!(
"treeboot: would sync metadata {} -> {}",
source.display(),
target.display()
);
}
return format!(
"treeboot: sync metadata {} -> {}",
source.display(),
target.display()
);
}
if !summary.expanded && dry_run {
return format!(
"treeboot: would {} {} -> {}",
operation.as_str(),
source.display(),
target.display()
);
}
if !summary.expanded {
return format!(
"treeboot: {} {} -> {}",
operation.as_str(),
source.display(),
target.display()
);
}
}
if summary.skipped == 1 {
let reason = summary.skip_reason.as_deref().unwrap_or("skipped");
if dry_run {
return format!(
"treeboot: would skip {} {}; {}",
operation.as_str(),
target.display(),
reason
);
}
return format!(
"treeboot: skip {} {}; {}",
operation.as_str(),
target.display(),
reason
);
}
}
let details = summary.count_details(dry_run).join(", ");
let suffix = if details.is_empty() {
String::new()
} else {
format!(" ({details})")
};
if dry_run {
format!(
"treeboot: would {} {} -> {}{suffix}",
operation.as_str(),
source.display(),
target.display()
)
} else {
format!(
"treeboot: {} {} -> {}{suffix}",
operation.as_str(),
source.display(),
target.display()
)
}
}
pub trait Reporter {
fn report(&mut self, event: OutputEvent) -> std::io::Result<()>;
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::FileOperationKind;
#[test]
fn message_should_format_ignored_init_script() {
let event = OutputEvent::IgnoredInitScript {
path: PathBuf::from(".treeboot.sh"),
};
assert_eq!(
event.message(),
"treeboot: ignore .treeboot.sh; not executable"
);
}
#[test]
fn message_should_format_dry_run_init_script() {
let event = OutputEvent::WouldRunInitScript {
path: PathBuf::from(".treeboot.sh"),
root_path: PathBuf::from("/repo"),
};
assert_eq!(event.message(), "treeboot: would run .treeboot.sh /repo");
}
#[test]
fn message_should_format_config_detected() {
let event = OutputEvent::ConfigDetected {
path: PathBuf::from(".treeboot.toml"),
};
assert_eq!(event.message(), "treeboot: config detected .treeboot.toml");
}
#[test]
fn message_should_format_file_applied() {
let event = OutputEvent::FileApplied {
operation: FileOperationKind::Copy,
source: PathBuf::from(".env"),
target: PathBuf::from(".env"),
};
assert_eq!(event.message(), "treeboot: copy .env -> .env");
}
#[test]
fn message_should_format_file_would_apply() {
let event = OutputEvent::FileWouldApply {
operation: FileOperationKind::Symlink,
source: PathBuf::from("tool"),
target: PathBuf::from(".tool"),
};
assert_eq!(event.message(), "treeboot: would symlink tool -> .tool");
}
#[test]
fn message_should_format_file_metadata_applied() {
let event = OutputEvent::FileMetadataApplied {
source: PathBuf::from("shared/config"),
target: PathBuf::from(".config"),
};
assert_eq!(
event.message(),
"treeboot: sync metadata shared/config -> .config"
);
}
#[test]
fn message_should_format_file_metadata_would_apply() {
let event = OutputEvent::FileMetadataWouldApply {
source: PathBuf::from("shared/config"),
target: PathBuf::from(".config"),
};
assert_eq!(
event.message(),
"treeboot: would sync metadata shared/config -> .config"
);
}
#[test]
fn message_should_format_file_skipped() {
let event = OutputEvent::FileSkipped {
operation: FileOperationKind::Copy,
target: PathBuf::from(".env"),
reason: "target exists".to_owned(),
};
assert_eq!(event.message(), "treeboot: skip copy .env; target exists");
}
#[test]
fn message_should_format_file_would_skip() {
let event = OutputEvent::FileWouldSkip {
operation: FileOperationKind::Sync,
target: PathBuf::from("shared"),
reason: "missing source".to_owned(),
};
assert_eq!(
event.message(),
"treeboot: would skip sync shared; missing source"
);
}
#[test]
fn message_should_omit_file_operation_lifecycle_events() {
let events = [
OutputEvent::FileOperationPlanningStarted {
operation: FileOperationKind::Copy,
source: PathBuf::from(".env"),
target: PathBuf::from(".env"),
},
OutputEvent::FileOperationPlanningFinished {
operation: FileOperationKind::Copy,
source: PathBuf::from(".env"),
target: PathBuf::from(".env"),
action_count: 1,
},
OutputEvent::FileOperationExecutionStarted {
operation: FileOperationKind::Copy,
source: PathBuf::from(".env"),
target: PathBuf::from(".env"),
action_count: 1,
},
OutputEvent::FileOperationActionAdvanced {
operation: FileOperationKind::Copy,
source: PathBuf::from(".env"),
target: PathBuf::from(".env"),
},
];
for event in events {
assert_eq!(event.message(), "");
}
}
#[test]
fn message_should_format_finished_file_operation_summary() {
let event = OutputEvent::FileOperationFinished {
operation: FileOperationKind::Sync,
source: PathBuf::from("shared"),
target: PathBuf::from("shared"),
summary: FileOperationSummary {
changed: 2,
deleted: 1,
expanded: true,
..FileOperationSummary::default()
},
dry_run: false,
};
assert_eq!(
event.message(),
"treeboot: sync shared -> shared (2 changed, 1 deleted)"
);
}
#[test]
fn message_should_format_file_deleted() {
let event = OutputEvent::FileDeleted {
path: PathBuf::from(".config/old.toml"),
};
assert_eq!(event.message(), "treeboot: delete .config/old.toml");
}
#[test]
fn message_should_format_file_would_delete() {
let event = OutputEvent::FileWouldDelete {
path: PathBuf::from(".config/old.toml"),
};
assert_eq!(event.message(), "treeboot: would delete .config/old.toml");
}
#[test]
fn message_should_format_file_warning() {
let event = OutputEvent::FileWarning {
path: PathBuf::from("shared/link"),
reason: "symlink target does not exist".to_owned(),
};
assert_eq!(
event.message(),
"treeboot: warning: shared/link symlink target does not exist"
);
}
#[test]
fn message_should_format_ownership_warning() {
let event = OutputEvent::OwnershipWarning {
path: PathBuf::from("shared/config"),
reason: "operation not permitted".to_owned(),
};
assert_eq!(
event.message(),
"treeboot: warning: could not preserve ownership shared/config: operation not permitted"
);
}
#[test]
fn message_should_format_single_file_operation_summary_without_counts() {
let summary = FileOperationSummary {
changed: 1,
..FileOperationSummary::default()
};
assert_eq!(
summary.message(
FileOperationKind::Copy,
Path::new(".env"),
Path::new(".env"),
false
),
"treeboot: copy .env -> .env"
);
}
#[test]
fn message_should_format_expanded_file_operation_summary_with_counts() {
let summary = FileOperationSummary {
changed: 4,
deleted: 1,
expanded: true,
..FileOperationSummary::default()
};
assert_eq!(
summary.message(
FileOperationKind::Sync,
Path::new("shared"),
Path::new("shared"),
false
),
"treeboot: sync shared -> shared (4 changed, 1 deleted)"
);
}
#[test]
fn message_should_omit_empty_file_operation_summary_counts() {
let summary = FileOperationSummary {
warnings: 1,
..FileOperationSummary::default()
};
assert_eq!(
summary.message(
FileOperationKind::Copy,
Path::new("shared/link"),
Path::new("shared/link"),
false
),
"treeboot: copy shared/link -> shared/link"
);
}
#[test]
fn message_should_format_single_dry_run_skip_summary() {
let summary = FileOperationSummary {
skipped: 1,
skip_reason: Some("target exists".to_owned()),
..FileOperationSummary::default()
};
assert_eq!(
summary.message(
FileOperationKind::Copy,
Path::new(".env"),
Path::new(".env"),
true
),
"treeboot: would skip copy .env; target exists"
);
}
#[test]
fn message_should_format_root_worktree_detected() {
let event = OutputEvent::RootWorktreeDetected;
assert_eq!(event.message(), "treeboot: This is not a work tree");
}
#[test]
fn message_should_format_command_started() {
let event = OutputEvent::CommandStarted {
label: "Install packages: npm install".to_owned(),
};
assert_eq!(
event.message(),
"treeboot: run Install packages: npm install"
);
}
#[test]
fn message_should_format_command_allowed_failure() {
let event = OutputEvent::CommandAllowedFailure {
label: "lint".to_owned(),
reason: "failed with exit status: 1".to_owned(),
};
assert_eq!(
event.message(),
"treeboot: warning: command lint failed with exit status: 1"
);
}
}