use std::fmt;
use std::path::{Component, 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(Self { 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(Self { segments })
}
#[must_use]
pub fn segments(&self) -> &[String] {
&self.segments
}
pub fn name(&self) -> &str {
self.segments.last().map_or("", String::as_str)
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
if self.segments.len() <= 1 {
None
} else {
Some(Self {
segments: self.segments[..self.segments.len() - 1].to_vec(),
})
}
}
#[must_use]
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("ConceptId is constructed 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::new();
for component in rel.components() {
let Component::Normal(segment) = component else {
return Err(ConceptIdError(format!(
"{} contains a non-normal path component",
path.display()
)));
};
let segment = segment.to_str().ok_or_else(|| {
ConceptIdError(format!(
"{} contains a path segment that is not valid UTF-8",
path.display()
))
})?;
segments.push(segment.to_string());
}
let Some(last) = segments.last_mut() else {
return Err(ConceptIdError(
"concept_id must have at least one segment".into(),
));
};
let Some(stripped) = last.strip_suffix(".md") else {
return Err(ConceptIdError(format!(
"{} does not name a markdown concept",
path.display()
)));
};
*last = stripped.to_string();
Self::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> {
Self::parse(s)
}
}
impl TryFrom<&str> for ConceptId {
type Error = ConceptIdError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl TryFrom<String> for ConceptId {
type Error = ConceptIdError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::parse(&s)
}
}
impl From<ConceptId> for String {
fn from(id: ConceptId) -> Self {
id.to_string()
}
}
impl AsRef<[String]> for ConceptId {
fn as_ref(&self) -> &[String] {
self.segments()
}
}
impl std::ops::Deref for ConceptId {
type Target = [String];
fn deref(&self) -> &[String] {
self.segments()
}
}
pub fn validate_segment(seg: &str) -> Result<(), ConceptIdError> {
let reject = |reason: &str| {
Err(ConceptIdError(format!(
"Invalid concept id segment: {seg:?} ({reason})"
)))
};
if seg.is_empty() {
return reject("empty");
}
if seg == "." || seg == ".." {
return reject("`.` and `..` cannot name a concept");
}
for c in seg.chars() {
if c == '/' || c == '\\' {
return reject("contains a path separator");
}
if c.is_control() {
return reject("contains a control character");
}
}
Ok(())
}
#[must_use]
pub fn is_portable_segment(seg: &str) -> bool {
let mut chars = seg.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
}