use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConceptIdError(pub String);
impl fmt::Display for ConceptIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ConceptIdError {}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ConceptId {
segments: Vec<String>,
}
impl ConceptId {
pub fn new(segments: Vec<String>) -> Result<Self, ConceptIdError> {
if segments.is_empty() {
return Err(ConceptIdError("concept_id must have at least one segment".into()));
}
for seg in &segments {
validate_segment(seg)?;
}
Ok(ConceptId { segments })
}
pub fn parse(s: &str) -> Result<Self, ConceptIdError> {
let segments: Vec<String> = s.split('/').filter(|p| !p.is_empty()).map(String::from).collect();
if segments.is_empty() {
return Err(ConceptIdError(format!("Empty concept id: {s:?}")));
}
for seg in &segments {
validate_segment(seg)?;
}
Ok(ConceptId { segments })
}
pub fn segments(&self) -> &[String] {
&self.segments
}
pub fn name(&self) -> &str {
self.segments.last().map(String::as_str).unwrap_or("")
}
pub fn parent(&self) -> Option<ConceptId> {
if self.segments.len() <= 1 {
None
} else {
Some(ConceptId {
segments: self.segments[..self.segments.len() - 1].to_vec(),
})
}
}
pub fn to_path(&self, bundle_root: &Path) -> PathBuf {
let mut path = bundle_root.to_path_buf();
let (name, dirs) = self.segments.split_last().expect("non-empty");
for d in dirs {
path.push(d);
}
path.push(format!("{name}.md"));
path
}
pub fn from_path(bundle_root: &Path, path: &Path) -> Result<Self, ConceptIdError> {
let rel = path
.strip_prefix(bundle_root)
.map_err(|_| ConceptIdError(format!("{} is not under bundle root", path.display())))?;
let mut segments: Vec<String> = Vec::new();
for comp in rel.components() {
let s = comp.as_os_str().to_string_lossy();
segments.push(s.to_string());
}
if let Some(last) = segments.last_mut() {
if let Some(stripped) = last.strip_suffix(".md") {
*last = stripped.to_string();
}
}
ConceptId::new(segments)
}
}
impl fmt::Display for ConceptId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.segments.join("/"))
}
}
impl std::str::FromStr for ConceptId {
type Err = ConceptIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
ConceptId::parse(s)
}
}
pub fn validate_segment(seg: &str) -> Result<(), ConceptIdError> {
let mut chars = seg.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
_ => return Err(ConceptIdError(format!("Invalid concept id segment: {seg:?}"))),
}
for c in chars {
if !(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') {
return Err(ConceptIdError(format!("Invalid concept id segment: {seg:?}")));
}
}
Ok(())
}