Skip to main content

reposix_core/
project.rs

1//! Project (top-level container of issues) types.
2
3use serde::{Deserialize, Serialize};
4
5/// A URL- and path-safe project identifier (e.g. `"demo"`, `"PROJ-A"`).
6///
7/// Validated to contain only `[A-Za-z0-9._-]` and to be 1-64 chars long. This guarantees we can
8/// safely render it as a directory name in the cache-materialized tree without path-traversal risk.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(transparent)]
11pub struct ProjectSlug(String);
12
13impl ProjectSlug {
14    /// Parse a slug, returning `None` if it contains disallowed characters or is a path
15    /// traversal sentinel (`.`, `..`).
16    #[must_use]
17    pub fn parse(s: &str) -> Option<Self> {
18        if s.is_empty() || s.len() > 64 || s == "." || s == ".." {
19            return None;
20        }
21        if s.bytes()
22            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
23        {
24            Some(Self(s.to_owned()))
25        } else {
26            None
27        }
28    }
29
30    /// Borrow the slug as a `&str`.
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37impl std::fmt::Display for ProjectSlug {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(&self.0)
40    }
41}
42
43/// A project: a named container of issues, with a configured workflow and permissions.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Project {
46    /// Stable URL-safe identifier.
47    pub slug: ProjectSlug,
48    /// Human-readable display name.
49    pub name: String,
50    /// Free-form description shown to agents in `index.md`.
51    #[serde(default)]
52    pub description: String,
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn slug_accepts_safe_chars() {
61        assert!(ProjectSlug::parse("demo").is_some());
62        assert!(ProjectSlug::parse("PROJ-123").is_some());
63        assert!(ProjectSlug::parse("my.project_v2").is_some());
64    }
65
66    #[test]
67    fn slug_rejects_path_traversal() {
68        assert!(ProjectSlug::parse("..").is_none());
69        assert!(ProjectSlug::parse("a/b").is_none());
70        assert!(ProjectSlug::parse("a\0b").is_none());
71        assert!(ProjectSlug::parse("").is_none());
72    }
73}