use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StatusKind {
Brainstorming,
Open,
InProgress,
Blocked,
Done,
Obsolete,
}
impl StatusKind {
fn from_wire(value: &str) -> Option<Self> {
match value {
"BRAINSTORMING" => Some(Self::Brainstorming),
"OPEN" => Some(Self::Open),
"IN_PROGRESS" => Some(Self::InProgress),
"BLOCKED" => Some(Self::Blocked),
"DONE" => Some(Self::Done),
"OBSOLETE" => Some(Self::Obsolete),
_ => None,
}
}
#[must_use]
pub const fn as_wire(&self) -> &'static str {
match self {
Self::Brainstorming => "BRAINSTORMING",
Self::Open => "OPEN",
Self::InProgress => "IN_PROGRESS",
Self::Blocked => "BLOCKED",
Self::Done => "DONE",
Self::Obsolete => "OBSOLETE",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum KindKind {
Feature,
Enhancement,
Fix,
Chore,
Spike,
Task,
}
impl KindKind {
fn from_wire(value: &str) -> Option<Self> {
match value {
"FEATURE" => Some(Self::Feature),
"ENHANCEMENT" => Some(Self::Enhancement),
"FIX" => Some(Self::Fix),
"CHORE" => Some(Self::Chore),
"SPIKE" => Some(Self::Spike),
"TASK" => Some(Self::Task),
_ => None,
}
}
#[must_use]
pub const fn as_wire(&self) -> &'static str {
match self {
Self::Feature => "FEATURE",
Self::Enhancement => "ENHANCEMENT",
Self::Fix => "FIX",
Self::Chore => "CHORE",
Self::Spike => "SPIKE",
Self::Task => "TASK",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AnnexRole {
Spec,
Plan,
Context,
}
impl AnnexRole {
#[must_use]
pub const fn prefix(&self) -> &'static str {
match self {
Self::Spec => "spec",
Self::Plan => "plan",
Self::Context => "context",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProjectMapLink {
Project(String),
Status(StatusKind),
Kind(KindKind),
Version {
project: String,
version: String,
},
Annex {
role: AnnexRole,
target: String,
},
Dep {
section: String,
ulid: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum SchemaError {
#[error("lien sans préfixe typé ni format section:ULID : {0:?}")]
MissingPrefix(String),
#[error(
"statut invalide {0:?} (attendu SCREAMING_SNAKE ∈ BRAINSTORMING/OPEN/IN_PROGRESS/BLOCKED/DONE/OBSOLETE)"
)]
InvalidStatus(String),
#[error(
"kind invalide {0:?} (attendu SCREAMING_SNAKE ∈ FEATURE/ENHANCEMENT/FIX/CHORE/SPIKE/TASK)"
)]
InvalidKind(String),
#[error("version mal formée {0:?} (attendu projet/x.y.z)")]
MalformedVersion(String),
#[error("valeur vide pour le préfixe {0:?}")]
EmptyValue(String),
#[error("valeur {1:?} trop longue pour le préfixe {0:?} (max 64 chars, reçu {2})")]
ValueTooLong(String, String, usize),
#[error(
"valeur {1:?} contient des caractères interdits pour le préfixe {0:?} (autorisé : a-z 0-9 . _ -)"
)]
InvalidChars(String, String),
#[error("exactement 1 lien project: requis, trouvé {0}")]
ProjectCardinality(usize),
#[error("exactement 1 lien status: requis, trouvé {0}")]
StatusCardinality(usize),
#[error("exactement 1 lien kind: requis, trouvé {0}")]
KindCardinality(usize),
#[error("au plus 1 lien version: autorisé, trouvé {0}")]
VersionCardinality(usize),
#[error("version namespacée {version_project:?} ≠ project {project:?}")]
VersionProjectMismatch {
project: String,
version_project: String,
},
}
const MAX_IDENT_LEN: usize = 64;
fn validate_ident(prefix: &str, value: &str) -> Result<(), SchemaError> {
if value.len() > MAX_IDENT_LEN {
return Err(SchemaError::ValueTooLong(
prefix.to_string(),
value.to_string(),
value.len(),
));
}
if !value
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
{
return Err(SchemaError::InvalidChars(
prefix.to_string(),
value.to_string(),
));
}
Ok(())
}
fn split_prefix(target: &str) -> (&str, &str) {
match target.split_once(':') {
Some((p, v)) => (p, v),
None => (target, ""),
}
}
#[must_use = "le résultat de parsing doit être inspecté"]
pub fn parse_link(raw: &str) -> Result<ProjectMapLink, SchemaError> {
let raw = raw.trim();
let (prefix, value) = split_prefix(raw);
let reserved = matches!(
prefix,
"project" | "status" | "kind" | "version" | "spec" | "plan" | "context"
);
if reserved && value.is_empty() {
return Err(SchemaError::EmptyValue(prefix.to_string()));
}
match prefix {
"project" => {
validate_ident("project", value)?;
Ok(ProjectMapLink::Project(value.to_string()))
}
"status" => StatusKind::from_wire(value)
.map(ProjectMapLink::Status)
.ok_or_else(|| SchemaError::InvalidStatus(value.to_string())),
"kind" => KindKind::from_wire(value)
.map(ProjectMapLink::Kind)
.ok_or_else(|| SchemaError::InvalidKind(value.to_string())),
"version" => parse_version(value),
"spec" => Ok(ProjectMapLink::Annex {
role: AnnexRole::Spec,
target: value.to_string(),
}),
"plan" => Ok(ProjectMapLink::Annex {
role: AnnexRole::Plan,
target: value.to_string(),
}),
"context" => Ok(ProjectMapLink::Annex {
role: AnnexRole::Context,
target: value.to_string(),
}),
other => parse_dep(other, value, raw),
}
}
fn parse_version(value: &str) -> Result<ProjectMapLink, SchemaError> {
match value.split_once('/') {
Some((project, version)) if !project.is_empty() && !version.is_empty() => {
validate_ident("version.project", project)?;
validate_ident("version.version", version)?;
Ok(ProjectMapLink::Version {
project: project.to_string(),
version: version.to_string(),
})
}
_ => Err(SchemaError::MalformedVersion(value.to_string())),
}
}
fn parse_dep(section: &str, ulid: &str, raw: &str) -> Result<ProjectMapLink, SchemaError> {
if ulid.is_empty() {
return Err(SchemaError::MissingPrefix(raw.to_string()));
}
Ok(ProjectMapLink::Dep {
section: section.to_string(),
ulid: ulid.to_string(),
})
}
#[must_use = "le résultat de validation doit être inspecté avant d'accepter l'écriture"]
pub fn validate_links(links: &[ProjectMapLink]) -> Result<(), SchemaError> {
let mut projects: Vec<&str> = Vec::new();
let mut status_count = 0usize;
let mut kind_count = 0usize;
let mut versions: Vec<&str> = Vec::new();
for link in links {
match link {
ProjectMapLink::Project(p) => projects.push(p),
ProjectMapLink::Status(_) => status_count += 1,
ProjectMapLink::Kind(_) => kind_count += 1,
ProjectMapLink::Version { project, .. } => versions.push(project),
ProjectMapLink::Annex { .. } | ProjectMapLink::Dep { .. } => {}
}
}
if projects.len() != 1 {
return Err(SchemaError::ProjectCardinality(projects.len()));
}
if status_count != 1 {
return Err(SchemaError::StatusCardinality(status_count));
}
if kind_count != 1 {
return Err(SchemaError::KindCardinality(kind_count));
}
if versions.len() > 1 {
return Err(SchemaError::VersionCardinality(versions.len()));
}
let project = projects[0];
if let Some(version_project) = versions.first()
&& *version_project != project
{
return Err(SchemaError::VersionProjectMismatch {
project: project.to_string(),
version_project: (*version_project).to_string(),
});
}
Ok(())
}
#[must_use = "le résultat de validation gate l'écriture project-map"]
pub fn validate_links_from_targets(targets: &[String]) -> Result<(), SchemaError> {
let links: Vec<ProjectMapLink> = targets.iter().filter_map(|t| parse_link(t).ok()).collect();
validate_links(&links)
}
#[must_use]
pub fn reserved_node_target(raw: &str) -> Option<String> {
match parse_link(raw).ok()? {
ProjectMapLink::Project(p) => Some(format!("project:{p}")),
ProjectMapLink::Status(s) => Some(format!("status:{}", s.as_wire())),
ProjectMapLink::Kind(k) => Some(format!("kind:{}", k.as_wire())),
ProjectMapLink::Version { project, version } => {
Some(format!("version:{project}/{version}"))
}
ProjectMapLink::Annex { .. } | ProjectMapLink::Dep { .. } => None,
}
}
#[cfg(test)]
mod validate_from_targets_tests {
use super::*;
fn t(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn valid_triple_with_prose_and_deps_passes() {
let targets = t(&[
"project:gradatum",
"status:IN_PROGRESS",
"kind:FEATURE",
"Mon Titre Humain", "decisions:01KVBTMYNK4XXZJAKWMTB4AM9K",
]);
assert_eq!(validate_links_from_targets(&targets), Ok(()));
}
#[test]
fn missing_status_is_rejected() {
let targets = t(&["project:gradatum", "kind:FIX"]);
assert_eq!(
validate_links_from_targets(&targets),
Err(SchemaError::StatusCardinality(0))
);
}
#[test]
fn invalid_status_value_fails_via_cardinality() {
let targets = t(&["project:gradatum", "status:nope", "kind:FIX"]);
assert_eq!(
validate_links_from_targets(&targets),
Err(SchemaError::StatusCardinality(0))
);
}
#[test]
fn empty_targets_is_rejected_no_project() {
assert_eq!(
validate_links_from_targets(&[]),
Err(SchemaError::ProjectCardinality(0))
);
}
}
#[cfg(test)]
mod reserved_node_tests {
use super::*;
#[test]
fn status_maps_to_canonical_reserved_node() {
assert_eq!(
reserved_node_target("status:DONE"),
Some("status:DONE".to_string())
);
}
#[test]
fn project_and_kind_and_version_map_to_reserved_nodes() {
assert_eq!(
reserved_node_target("project:gradatum"),
Some("project:gradatum".to_string())
);
assert_eq!(
reserved_node_target("kind:FIX"),
Some("kind:FIX".to_string())
);
assert_eq!(
reserved_node_target("version:gradatum/0.6.1"),
Some("version:gradatum/0.6.1".to_string())
);
}
#[test]
fn status_node_is_normalised_to_wire_casing() {
assert_eq!(reserved_node_target("status:done"), None);
}
#[test]
fn annexes_are_not_reserved_nodes() {
assert_eq!(
reserved_node_target("spec:01KVBTMYNK4XXZJAKWMTB4AM9K"),
None
);
assert_eq!(reserved_node_target("plan:plans/x.md"), None);
assert_eq!(
reserved_node_target("context:01KVBTMYNK4XXZJAKWMTB4AM9K"),
None
);
}
#[test]
fn dependency_section_ulid_is_not_a_reserved_node_unregressed() {
assert_eq!(
reserved_node_target("decisions:01KVBTMYNK4XXZJAKWMTB4AM9K"),
None
);
}
#[test]
fn human_title_is_not_a_reserved_node() {
assert_eq!(reserved_node_target("Mon Titre Humain"), None);
}
}
#[cfg(test)]
mod parse_tests {
use super::*;
#[test]
fn project_prefix_parses_to_project() {
assert_eq!(
parse_link("project:gradatum"),
Ok(ProjectMapLink::Project("gradatum".to_string()))
);
}
#[test]
fn status_done_parses_to_status_done() {
assert_eq!(
parse_link("status:DONE"),
Ok(ProjectMapLink::Status(StatusKind::Done))
);
}
#[test]
fn status_lowercase_is_rejected_case_sensitive() {
assert_eq!(
parse_link("status:done"),
Err(SchemaError::InvalidStatus("done".to_string()))
);
}
#[test]
fn status_unknown_value_is_rejected() {
assert_eq!(
parse_link("status:NOPE"),
Err(SchemaError::InvalidStatus("NOPE".to_string()))
);
}
#[test]
fn kind_fix_parses_to_kind_fix() {
assert_eq!(
parse_link("kind:FIX"),
Ok(ProjectMapLink::Kind(KindKind::Fix))
);
}
#[test]
fn kind_chore_and_spike_are_accepted() {
assert_eq!(
parse_link("kind:CHORE"),
Ok(ProjectMapLink::Kind(KindKind::Chore))
);
assert_eq!(
parse_link("kind:SPIKE"),
Ok(ProjectMapLink::Kind(KindKind::Spike))
);
}
#[test]
fn kind_unknown_value_is_rejected() {
assert_eq!(
parse_link("kind:BUGFIX"),
Err(SchemaError::InvalidKind("BUGFIX".to_string()))
);
}
#[test]
fn version_namespaced_parses_to_version() {
assert_eq!(
parse_link("version:gradatum/0.6.1"),
Ok(ProjectMapLink::Version {
project: "gradatum".to_string(),
version: "0.6.1".to_string(),
})
);
}
#[test]
fn version_without_slash_is_malformed() {
assert_eq!(
parse_link("version:0.6.1"),
Err(SchemaError::MalformedVersion("0.6.1".to_string()))
);
}
#[test]
fn version_empty_project_is_malformed() {
assert_eq!(
parse_link("version:/0.6.1"),
Err(SchemaError::MalformedVersion("/0.6.1".to_string()))
);
}
#[test]
fn annex_spec_plan_context_parse_to_annex() {
assert_eq!(
parse_link("spec:01KVBTMYNK4XXZJAKWMTB4AM9K"),
Ok(ProjectMapLink::Annex {
role: AnnexRole::Spec,
target: "01KVBTMYNK4XXZJAKWMTB4AM9K".to_string(),
})
);
assert_eq!(
parse_link("plan:plans/2026-06-19.md"),
Ok(ProjectMapLink::Annex {
role: AnnexRole::Plan,
target: "plans/2026-06-19.md".to_string(),
})
);
assert_eq!(
parse_link("context:01KVBTMYNK4XXZJAKWMTB4AM9K"),
Ok(ProjectMapLink::Annex {
role: AnnexRole::Context,
target: "01KVBTMYNK4XXZJAKWMTB4AM9K".to_string(),
})
);
}
#[test]
fn dep_section_ulid_parses_to_dep_unregressed() {
assert_eq!(
parse_link("decisions:01KVBTMYNK4XXZJAKWMTB4AM9K"),
Ok(ProjectMapLink::Dep {
section: "decisions".to_string(),
ulid: "01KVBTMYNK4XXZJAKWMTB4AM9K".to_string(),
})
);
}
#[test]
fn bare_value_without_prefix_is_missing_prefix() {
assert_eq!(
parse_link("Mon Titre Humain"),
Err(SchemaError::MissingPrefix("Mon Titre Humain".to_string()))
);
}
#[test]
fn reserved_prefix_with_empty_value_is_rejected() {
assert_eq!(
parse_link("project:"),
Err(SchemaError::EmptyValue("project".to_string()))
);
assert_eq!(
parse_link("status:"),
Err(SchemaError::EmptyValue("status".to_string()))
);
}
#[test]
fn leading_trailing_whitespace_is_trimmed() {
assert_eq!(
parse_link(" project:gradatum "),
Ok(ProjectMapLink::Project("gradatum".to_string()))
);
}
#[test]
fn project_path_traversal_is_rejected_invalid_chars() {
let err = parse_link("project:../../etc").unwrap_err();
assert!(
matches!(err, SchemaError::InvalidChars(ref p, _) if p == "project"),
"attendu InvalidChars(project, …), reçu {err:?}"
);
}
#[test]
fn project_name_over_64_chars_is_rejected() {
let long_name = "a".repeat(65);
let err = parse_link(&format!("project:{long_name}")).unwrap_err();
assert!(
matches!(err, SchemaError::ValueTooLong(ref p, _, 65) if p == "project"),
"attendu ValueTooLong(project, _, 65), reçu {err:?}"
);
}
#[test]
fn project_uppercase_is_rejected() {
let err = parse_link("project:My-Project").unwrap_err();
assert!(
matches!(err, SchemaError::InvalidChars(ref p, _) if p == "project"),
"attendu InvalidChars(project, …), reçu {err:?}"
);
}
#[test]
fn version_project_uppercase_is_rejected() {
let err = parse_link("version:My_Project/0.6.1").unwrap_err();
assert!(
matches!(err, SchemaError::InvalidChars(ref p, _) if p == "version.project"),
"attendu InvalidChars(version.project, …), reçu {err:?}"
);
}
#[test]
fn version_version_with_space_is_rejected() {
let err = parse_link("version:gradatum/1.0.0 evil").unwrap_err();
assert!(
matches!(err, SchemaError::InvalidChars(ref p, _) if p == "version.version"),
"attendu InvalidChars(version.version, …), reçu {err:?}"
);
}
#[test]
fn project_with_dash_underscore_dot_is_valid() {
assert_eq!(
parse_link("project:my-project_v1.2"),
Ok(ProjectMapLink::Project("my-project_v1.2".to_string()))
);
}
#[test]
fn version_with_semver_prerelease_is_valid() {
assert_eq!(
parse_link("version:my-proj/1.2.3-rc.1"),
Ok(ProjectMapLink::Version {
project: "my-proj".to_string(),
version: "1.2.3-rc.1".to_string(),
})
);
}
#[test]
fn status_kind_wire_roundtrip() {
for s in [
StatusKind::Brainstorming,
StatusKind::Open,
StatusKind::InProgress,
StatusKind::Blocked,
StatusKind::Done,
StatusKind::Obsolete,
] {
assert_eq!(StatusKind::from_wire(s.as_wire()), Some(s));
}
for k in [
KindKind::Feature,
KindKind::Enhancement,
KindKind::Fix,
KindKind::Chore,
KindKind::Spike,
KindKind::Task,
] {
assert_eq!(KindKind::from_wire(k.as_wire()), Some(k));
}
}
}
#[cfg(test)]
mod validate_tests {
use super::*;
fn minimal_valid() -> Vec<ProjectMapLink> {
vec![
ProjectMapLink::Project("gradatum".to_string()),
ProjectMapLink::Status(StatusKind::Open),
ProjectMapLink::Kind(KindKind::Feature),
]
}
#[test]
fn minimal_triple_is_valid() {
assert_eq!(validate_links(&minimal_valid()), Ok(()));
}
#[test]
fn zero_project_is_rejected() {
let links = vec![
ProjectMapLink::Status(StatusKind::Open),
ProjectMapLink::Kind(KindKind::Feature),
];
assert_eq!(
validate_links(&links),
Err(SchemaError::ProjectCardinality(0))
);
}
#[test]
fn two_projects_is_rejected() {
let mut links = minimal_valid();
links.push(ProjectMapLink::Project("other".to_string()));
assert_eq!(
validate_links(&links),
Err(SchemaError::ProjectCardinality(2))
);
}
#[test]
fn zero_kind_is_rejected() {
let links = vec![
ProjectMapLink::Project("gradatum".to_string()),
ProjectMapLink::Status(StatusKind::Open),
];
assert_eq!(validate_links(&links), Err(SchemaError::KindCardinality(0)));
}
#[test]
fn two_status_is_rejected() {
let mut links = minimal_valid();
links.push(ProjectMapLink::Status(StatusKind::Done));
assert_eq!(
validate_links(&links),
Err(SchemaError::StatusCardinality(2))
);
}
#[test]
fn two_versions_is_rejected() {
let mut links = minimal_valid();
links.push(ProjectMapLink::Version {
project: "gradatum".to_string(),
version: "0.6.1".to_string(),
});
links.push(ProjectMapLink::Version {
project: "gradatum".to_string(),
version: "0.6.2".to_string(),
});
assert_eq!(
validate_links(&links),
Err(SchemaError::VersionCardinality(2))
);
}
#[test]
fn version_project_mismatch_is_rejected() {
let mut links = minimal_valid();
links.push(ProjectMapLink::Version {
project: "example-project".to_string(),
version: "0.6.1".to_string(),
});
assert_eq!(
validate_links(&links),
Err(SchemaError::VersionProjectMismatch {
project: "gradatum".to_string(),
version_project: "example-project".to_string(),
})
);
}
#[test]
fn one_version_matching_project_is_valid() {
let mut links = minimal_valid();
links.push(ProjectMapLink::Version {
project: "gradatum".to_string(),
version: "0.6.1".to_string(),
});
assert_eq!(validate_links(&links), Ok(()));
}
#[test]
fn annexes_and_deps_in_any_number_are_valid() {
let mut links = minimal_valid();
links.push(ProjectMapLink::Annex {
role: AnnexRole::Spec,
target: "01KVBTMYNK4XXZJAKWMTB4AM9K".to_string(),
});
links.push(ProjectMapLink::Annex {
role: AnnexRole::Plan,
target: "plans/x.md".to_string(),
});
links.push(ProjectMapLink::Dep {
section: "decisions".to_string(),
ulid: "01KVBTMYNK4XXZJAKWMTB4AM9K".to_string(),
});
links.push(ProjectMapLink::Dep {
section: "council".to_string(),
ulid: "01KVBTMYNK4XXZJAKWMTB4AM9C".to_string(),
});
assert_eq!(validate_links(&links), Ok(()));
}
}