use std::io;
use std::path::PathBuf;
use thiserror::Error;
use crate::git::GitError;
#[must_use]
pub fn is_not_a_directory(err: &io::Error) -> bool {
match err.raw_os_error() {
#[cfg(unix)]
Some(20) => true,
#[cfg(windows)]
Some(267) => true,
_ => false,
}
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum TreeError {
#[error("pack manifest not found at `{0}`")]
ManifestNotFound(PathBuf),
#[error("failed to read pack manifest: {0}")]
ManifestRead(String),
#[error("permission denied reading pack manifest at `{path}`")]
ManifestPermissionDenied {
path: PathBuf,
},
#[error("manifest path `{path}` is not a directory (or has wrong type)")]
ManifestNotADir {
path: PathBuf,
},
#[error("I/O error reading pack manifest at `{path}`: {source}")]
ManifestIo {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to parse pack manifest at `{path}`: {detail}")]
ManifestParse {
path: PathBuf,
detail: String,
},
#[error("git error during walk: {0}")]
Git(#[from] GitError),
#[error("{}", display_cycle_detected(chain))]
CycleDetected {
chain: Vec<String>,
},
#[error("pack name `{got}` does not match expected `{expected}` for child at `{path}`")]
PackNameMismatch {
got: String,
expected: String,
path: PathBuf,
},
#[error("pack child `{child_name}` has invalid path `{path}`: {reason}")]
ChildPathInvalid {
child_name: String,
path: String,
reason: String,
},
#[error("v1.1.1 lockfile detected at {path}, run grex migrate-lockfile")]
LegacyLockfileDetected {
path: PathBuf,
},
#[error(
"untracked git repositories found: {}; either register them as packs or remove from manifest",
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
)]
UntrackedGitRepos {
paths: Vec<PathBuf>,
},
#[error("{}", display_dirty_tree_refusal(path, kind))]
DirtyTreeRefusal {
path: PathBuf,
kind: DirtyTreeRefusalKind,
},
#[error("manifest path '{path}' escapes parent boundary: {reason}")]
ManifestPathEscape {
path: String,
reason: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyTreeRefusalKind {
DirtyTree,
DirtyTreeWithIgnored,
GitInProgress,
SubMetaWithDirtyChildren,
}
fn display_cycle_detected(chain: &[String]) -> String {
if chain.is_empty() {
return "cycle detected in pack graph (empty chain)".to_string();
}
format!("cycle detected in pack graph: {}", chain.join(" → "))
}
fn display_dirty_tree_refusal(path: &std::path::Path, kind: &DirtyTreeRefusalKind) -> String {
match kind {
DirtyTreeRefusalKind::DirtyTree => {
format!("refusing to prune {}: working tree dirty", path.display())
}
DirtyTreeRefusalKind::DirtyTreeWithIgnored => format!(
"refusing to prune {}: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
path.display()
),
DirtyTreeRefusalKind::GitInProgress => format!(
"refusing to prune {}: in-progress git operation (rebase/merge/cherry-pick)",
path.display()
),
DirtyTreeRefusalKind::SubMetaWithDirtyChildren => format!(
"refusing to prune {}: nested meta-repo has dirty children",
path.display()
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tree_error_legacy_lockfile_detected_display() {
let err = TreeError::LegacyLockfileDetected {
path: PathBuf::from("/repos/code/.grex/lock.yaml"),
};
assert_eq!(
err.to_string(),
"v1.1.1 lockfile detected at /repos/code/.grex/lock.yaml, run grex migrate-lockfile",
);
}
#[test]
fn test_tree_error_untracked_git_repos_display_single() {
let err = TreeError::UntrackedGitRepos { paths: vec![PathBuf::from("alpha")] };
assert_eq!(
err.to_string(),
"untracked git repositories found: alpha; either register them as packs or remove from manifest",
);
}
#[test]
fn test_tree_error_untracked_git_repos_display_multiple() {
let err = TreeError::UntrackedGitRepos {
paths: vec![PathBuf::from("alpha"), PathBuf::from("beta"), PathBuf::from("gamma")],
};
assert_eq!(
err.to_string(),
"untracked git repositories found: alpha, beta, gamma; either register them as packs or remove from manifest",
);
}
#[test]
fn test_tree_error_dirty_tree_refusal_display_dirty_tree() {
let err = TreeError::DirtyTreeRefusal {
path: PathBuf::from("alpha"),
kind: DirtyTreeRefusalKind::DirtyTree,
};
assert_eq!(err.to_string(), "refusing to prune alpha: working tree dirty");
}
#[test]
fn test_tree_error_dirty_tree_refusal_display_dirty_with_ignored() {
let err = TreeError::DirtyTreeRefusal {
path: PathBuf::from("alpha"),
kind: DirtyTreeRefusalKind::DirtyTreeWithIgnored,
};
assert_eq!(
err.to_string(),
"refusing to prune alpha: working tree dirty (including ignored files); use --force-prune-with-ignored to override",
);
}
#[test]
fn test_tree_error_dirty_tree_refusal_display_git_in_progress() {
let err = TreeError::DirtyTreeRefusal {
path: PathBuf::from("alpha"),
kind: DirtyTreeRefusalKind::GitInProgress,
};
assert_eq!(
err.to_string(),
"refusing to prune alpha: in-progress git operation (rebase/merge/cherry-pick)",
);
}
#[test]
fn test_tree_error_dirty_tree_refusal_display_sub_meta_dirty() {
let err = TreeError::DirtyTreeRefusal {
path: PathBuf::from("alpha"),
kind: DirtyTreeRefusalKind::SubMetaWithDirtyChildren,
};
assert_eq!(err.to_string(), "refusing to prune alpha: nested meta-repo has dirty children",);
}
#[test]
fn test_tree_error_manifest_path_escape_display() {
let err = TreeError::ManifestPathEscape {
path: "../escape".into(),
reason: "child path escapes parent root".into(),
};
assert_eq!(
err.to_string(),
"manifest path '../escape' escapes parent boundary: child path escapes parent root",
);
}
#[test]
fn test_tree_error_manifest_permission_denied_display() {
let err = TreeError::ManifestPermissionDenied {
path: PathBuf::from("/repos/code/.grex/pack.yaml"),
};
assert_eq!(
err.to_string(),
"permission denied reading pack manifest at `/repos/code/.grex/pack.yaml`",
);
}
#[test]
fn test_tree_error_manifest_not_a_dir_display() {
let err = TreeError::ManifestNotADir { path: PathBuf::from("/repos/code/.grex/pack.yaml") };
assert_eq!(
err.to_string(),
"manifest path `/repos/code/.grex/pack.yaml` is not a directory (or has wrong type)",
);
}
#[test]
fn test_tree_error_manifest_io_display_and_source() {
use std::error::Error as _;
let underlying = io::Error::other("disk on fire");
let err = TreeError::ManifestIo {
path: PathBuf::from("/repos/code/.grex/pack.yaml"),
source: underlying,
};
assert_eq!(
err.to_string(),
"I/O error reading pack manifest at `/repos/code/.grex/pack.yaml`: disk on fire",
);
let source = err.source().expect("ManifestIo carries a source");
let downcast = source.downcast_ref::<io::Error>().expect("source downcasts to io::Error");
assert_eq!(downcast.kind(), io::ErrorKind::Other);
}
#[test]
fn test_is_not_a_directory_helper_matches_platform_code() {
#[cfg(unix)]
{
let e = io::Error::from_raw_os_error(20);
assert!(is_not_a_directory(&e), "POSIX ENOTDIR (20) must be detected");
}
#[cfg(windows)]
{
let e = io::Error::from_raw_os_error(267);
assert!(is_not_a_directory(&e), "Windows ERROR_DIRECTORY (267) must be detected");
}
}
#[test]
fn test_is_not_a_directory_helper_rejects_unrelated_codes() {
let perm = io::Error::from(io::ErrorKind::PermissionDenied);
assert!(!is_not_a_directory(&perm));
let nf = io::Error::from(io::ErrorKind::NotFound);
assert!(!is_not_a_directory(&nf));
let other = io::Error::other("no os code");
assert!(!is_not_a_directory(&other));
}
}