use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::error::AtomwriteError;
pub const STDIN_TARGET: &str = "-";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetSource {
Argv,
Workspace,
Stdin,
}
impl TargetSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::Argv => "argv",
Self::Workspace => "workspace",
Self::Stdin => "stdin",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetMode {
Explicit,
AmbientAllowed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedTarget {
pub target_resolved: PathBuf,
pub target_source: TargetSource,
}
pub fn resolve_mutation_targets(
paths: &[PathBuf],
workspace: &Path,
mode: TargetMode,
) -> Result<Vec<ResolvedTarget>> {
if paths.is_empty() {
return match mode {
TargetMode::Explicit => Err(AtomwriteError::InvalidInput {
reason: "target required in argv: a verb with side effects never inherits its \
subject from the workspace or the current directory; name the target \
explicitly (e.g. `src/` or `.`)"
.into(),
}
.into()),
TargetMode::AmbientAllowed => {
let target_resolved = crate::path_safety::validate_path(workspace, workspace)
.context("G121: cannot resolve the workspace root as the ambient target")?;
Ok(vec![ResolvedTarget {
target_resolved,
target_source: TargetSource::Workspace,
}])
}
};
}
resolve_each_root(paths, workspace)
}
fn resolve_each_root(paths: &[PathBuf], workspace: &Path) -> Result<Vec<ResolvedTarget>> {
paths
.iter()
.map(|p| {
let target_resolved =
crate::path_safety::validate_path(p, workspace).with_context(|| {
format!(
"G121: path '{}' escapes workspace jail; use --workspace to set a different root",
p.display()
)
})?;
Ok(ResolvedTarget {
target_resolved,
target_source: TargetSource::Argv,
})
})
.collect()
}
pub fn resolve_paths_against_workspace(
paths: &[PathBuf],
workspace: &Path,
) -> Result<Vec<PathBuf>> {
paths
.iter()
.map(|p| {
crate::path_safety::validate_path(p, workspace).with_context(|| {
format!(
"G121: path '{}' escapes workspace jail; use --workspace to set a different root",
p.display()
)
})
})
.collect()
}
#[must_use]
pub fn split_missing_roots(paths: Vec<PathBuf>) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut present = Vec::with_capacity(paths.len());
let mut missing = Vec::new();
for path in paths {
match std::fs::metadata(&path) {
Ok(_) => present.push(path),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => missing.push(path),
Err(_) => present.push(path),
}
}
(present, missing)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn resolves_relative_path_against_workspace() {
let dir = tempdir().expect("tempdir");
let workspace = dir.path();
let sub = workspace.join("src");
std::fs::create_dir(&sub).expect("mkdir");
let resolved =
resolve_paths_against_workspace(&[PathBuf::from("src")], workspace).expect("resolve");
assert_eq!(resolved.len(), 1);
assert!(resolved[0].ends_with("src"));
assert!(resolved[0].is_absolute());
}
#[test]
fn resolves_absolute_path_inside_workspace() {
let dir = tempdir().expect("tempdir");
let workspace = dir.path();
let target = workspace.join("data");
std::fs::create_dir(&target).expect("mkdir");
let resolved = resolve_paths_against_workspace(std::slice::from_ref(&target), workspace)
.expect("resolve");
assert_eq!(
resolved[0],
resolve_paths_against_workspace(std::slice::from_ref(&target), workspace).unwrap()[0]
);
}
#[test]
fn rejects_path_outside_workspace() {
let dir = tempdir().expect("tempdir");
let result = resolve_paths_against_workspace(&[PathBuf::from("/etc/passwd")], dir.path());
assert!(result.is_err(), "out-of-jail path must fail");
}
#[test]
fn explicit_mode_rejects_empty_targets() {
let dir = tempdir().expect("tempdir");
let err = resolve_mutation_targets(&[], dir.path(), TargetMode::Explicit)
.expect_err("empty target must fail closed");
let aw_err = err
.downcast_ref::<AtomwriteError>()
.expect("typed AtomwriteError");
assert_eq!(aw_err.exit_code(), 65);
let msg = format!("{err:#}");
assert!(msg.contains("target required in argv"), "unexpected: {msg}");
}
#[test]
fn explicit_mode_keeps_named_target_and_source() {
let dir = tempdir().expect("tempdir");
let named = dir.path().join("sub");
std::fs::create_dir(&named).expect("mkdir");
let resolved = resolve_mutation_targets(
std::slice::from_ref(&named),
dir.path(),
TargetMode::Explicit,
)
.expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].target_source, TargetSource::Argv);
assert_eq!(resolved[0].target_resolved, named);
}
#[test]
fn ambient_mode_uses_workspace_as_target() {
let dir = tempdir().expect("tempdir");
let resolved = resolve_mutation_targets(&[], dir.path(), TargetMode::AmbientAllowed)
.expect("resolve ambient");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].target_source, TargetSource::Workspace);
assert!(resolved[0].target_resolved.is_absolute());
}
#[test]
fn mutation_target_outside_workspace_is_rejected() {
let dir = tempdir().expect("tempdir");
let err = resolve_mutation_targets(
&[PathBuf::from("/etc/passwd")],
dir.path(),
TargetMode::Explicit,
)
.expect_err("out-of-jail target must fail");
assert!(err.to_string().contains("G121"), "unexpected: {err:#}");
}
#[test]
fn preserves_input_count() {
let dir = tempdir().expect("tempdir");
let p1 = dir.path().join("a");
let p2 = dir.path().join("b");
std::fs::create_dir(&p1).expect("mkdir a");
std::fs::create_dir(&p2).expect("mkdir b");
let resolved =
resolve_paths_against_workspace(&[PathBuf::from("a"), PathBuf::from("b")], dir.path())
.expect("resolve");
assert_eq!(resolved.len(), 2);
}
}