use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::domain::ownership::Sha256;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TargetPath(Utf8PathBuf);
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{0}")]
pub struct TargetPathError(String);
impl TargetPath {
pub fn new(value: &str) -> Result<Self, TargetPathError> {
let refuse = |why: &str| TargetPathError(format!("{value}: {why}"));
if value.is_empty() {
return Err(refuse("the path is empty"));
}
if value.contains('\0') {
return Err(refuse("the path carries a NUL"));
}
let path = Utf8Path::new(value);
if path.is_absolute() {
return Err(refuse("the path is absolute"));
}
let mut normalized = Utf8PathBuf::new();
for component in path.components() {
match component {
camino::Utf8Component::Normal(part) => normalized.push(part),
camino::Utf8Component::CurDir => {}
camino::Utf8Component::ParentDir => {
return Err(refuse("the path climbs out of the target"));
}
camino::Utf8Component::RootDir | camino::Utf8Component::Prefix(_) => {
return Err(refuse("the path is absolute"));
}
}
}
if normalized.as_str().is_empty() {
return Err(refuse("the path names no file"));
}
Ok(Self(normalized))
}
#[must_use]
pub fn as_path(&self) -> &Utf8Path {
&self.0
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl TryFrom<String> for TargetPath {
type Error = TargetPathError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(&value)
}
}
impl From<TargetPath> for String {
fn from(value: TargetPath) -> Self {
value.0.into_string()
}
}
impl std::fmt::Display for TargetPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Class {
Managed,
Adopted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Operation {
WriteFile {
path: TargetPath,
class: Class,
before: Option<Sha256>,
after: Sha256,
},
KeepFile {
path: TargetPath,
held: Sha256,
baseline_before: Sha256,
baseline_after: Sha256,
},
SpliceBlock {
path: TargetPath,
marker: String,
before: Option<Sha256>,
after: Sha256,
},
RemoveOwnedFile {
path: TargetPath,
before: Sha256,
},
WriteDebt {
path: TargetPath,
before: Option<Sha256>,
after: Sha256,
},
WriteRecord {
path: TargetPath,
before: Option<Sha256>,
after: Sha256,
},
}
impl Operation {
#[must_use]
pub const fn path(&self) -> &TargetPath {
match self {
Self::WriteFile { path, .. }
| Self::KeepFile { path, .. }
| Self::SpliceBlock { path, .. }
| Self::RemoveOwnedFile { path, .. }
| Self::WriteDebt { path, .. }
| Self::WriteRecord { path, .. } => path,
}
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::WriteFile { .. } => "write-file",
Self::KeepFile { .. } => "keep-file",
Self::SpliceBlock { .. } => "splice-block",
Self::RemoveOwnedFile { .. } => "remove-owned-file",
Self::WriteDebt { .. } => "write-debt",
Self::WriteRecord { .. } => "write-record",
}
}
#[must_use]
pub const fn before(&self) -> Option<&Sha256> {
match self {
Self::WriteFile { before, .. }
| Self::SpliceBlock { before, .. }
| Self::WriteDebt { before, .. }
| Self::WriteRecord { before, .. } => before.as_ref(),
Self::RemoveOwnedFile { before, .. } => Some(before),
Self::KeepFile { held, .. } => Some(held),
}
}
#[must_use]
pub const fn after(&self) -> Option<&Sha256> {
match self {
Self::WriteFile { after, .. }
| Self::SpliceBlock { after, .. }
| Self::WriteDebt { after, .. }
| Self::WriteRecord { after, .. } => Some(after),
Self::KeepFile { held, .. } => Some(held),
Self::RemoveOwnedFile { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{0} is written by two operations, {1} and {2}")]
pub struct DuplicateDestination(TargetPath, &'static str, &'static str);
pub fn no_duplicate_destination(operations: &[Operation]) -> Result<(), DuplicateDestination> {
for (index, operation) in operations.iter().enumerate() {
for other in &operations[index + 1..] {
if operation.path() == other.path() {
return Err(DuplicateDestination(
operation.path().clone(),
operation.kind(),
other.kind(),
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn path(value: &str) -> TargetPath {
TargetPath::new(value).unwrap()
}
#[test]
fn an_ordinary_relative_path_is_accepted_and_normalized() {
assert_eq!(
path("docs/specs/SPEC-x.md").as_str(),
"docs/specs/SPEC-x.md"
);
assert_eq!(path("./docs/x.md").as_str(), "docs/x.md");
assert_eq!(path("docs//x.md").as_str(), "docs/x.md");
}
#[test]
fn a_path_a_plan_may_not_name_is_refused() {
for value in ["", "/etc/passwd", "../escape.md", "docs/../../out.md"] {
assert!(TargetPath::new(value).is_err(), "{value} was accepted");
}
assert!(TargetPath::new("docs/\0.md").is_err());
assert!(TargetPath::new("./").is_err());
}
#[test]
fn a_path_round_trips_through_its_string_form() {
let held = path("docs/x.md");
let json = serde_json::to_string(&held).unwrap();
assert_eq!(json, "\"docs/x.md\"");
assert_eq!(serde_json::from_str::<TargetPath>(&json).unwrap(), held);
assert!(serde_json::from_str::<TargetPath>("\"../x.md\"").is_err());
}
#[test]
fn every_operation_names_its_path_and_its_kind() {
let write = Operation::WriteFile {
path: path("a.md"),
class: Class::Managed,
before: None,
after: Sha256::of(b"a"),
};
assert_eq!(write.kind(), "write-file");
assert_eq!(write.path().as_str(), "a.md");
assert_eq!(write.before(), None);
assert_eq!(write.after(), Some(&Sha256::of(b"a")));
let remove = Operation::RemoveOwnedFile {
path: path("b.md"),
before: Sha256::of(b"b"),
};
assert_eq!(remove.after(), None);
assert_eq!(remove.before(), Some(&Sha256::of(b"b")));
}
#[test]
fn a_kept_file_holds_its_bytes_and_moves_only_the_baseline() {
let kept = Operation::KeepFile {
path: path("docs/specs/SPEC-x.md"),
held: Sha256::of(b"mine"),
baseline_before: Sha256::of(b"old seed"),
baseline_after: Sha256::of(b"new seed"),
};
assert_eq!(kept.before(), kept.after());
}
#[test]
fn one_destination_written_twice_is_refused() {
let operations = vec![
Operation::WriteFile {
path: path("a.md"),
class: Class::Managed,
before: None,
after: Sha256::of(b"a"),
},
Operation::WriteDebt {
path: path("a.md"),
before: None,
after: Sha256::of(b"b"),
},
];
let error = no_duplicate_destination(&operations).unwrap_err();
assert!(error.to_string().contains("a.md"), "{error}");
assert!(no_duplicate_destination(&operations[..1]).is_ok());
}
}