Skip to main content

parsnip_core/
project.rs

1//! Project (namespace) types and operations
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use ulid::Ulid;
6
7/// Unique identifier for a project
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct ProjectId(pub Ulid);
10
11impl ProjectId {
12    pub fn new() -> Self {
13        Self(Ulid::new())
14    }
15
16    pub fn from_string(s: &str) -> Result<Self, ulid::DecodeError> {
17        Ok(Self(Ulid::from_string(s)?))
18    }
19}
20
21impl Default for ProjectId {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl std::fmt::Display for ProjectId {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(f, "{}", self.0)
30    }
31}
32
33/// A project (namespace) in the knowledge graph
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Project {
36    /// Unique identifier
37    pub id: ProjectId,
38
39    /// Project name (unique, alphanumeric with underscores/hyphens)
40    pub name: String,
41
42    /// Human-readable description
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub description: Option<String>,
45
46    /// Creation timestamp
47    pub created_at: DateTime<Utc>,
48
49    /// Project settings
50    #[serde(default)]
51    pub settings: ProjectSettings,
52}
53
54impl Project {
55    /// Create a new project
56    pub fn new(name: impl Into<String>) -> Self {
57        Self {
58            id: ProjectId::new(),
59            name: name.into(),
60            description: None,
61            created_at: Utc::now(),
62            settings: ProjectSettings::default(),
63        }
64    }
65
66    /// Create project with description
67    pub fn with_description(mut self, description: impl Into<String>) -> Self {
68        self.description = Some(description.into());
69        self
70    }
71
72    /// Validate project name (alphanumeric, underscores, hyphens only)
73    pub fn validate_name(name: &str) -> bool {
74        !name.is_empty()
75            && name.len() <= 100
76            && name
77                .chars()
78                .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
79    }
80}
81
82/// Project-specific settings
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct ProjectSettings {
85    /// Whether to enable full-text search indexing
86    #[serde(default = "default_true")]
87    pub fulltext_enabled: bool,
88
89    /// Default fuzzy search threshold
90    #[serde(default = "default_fuzzy_threshold")]
91    pub fuzzy_threshold: f32,
92}
93
94fn default_true() -> bool {
95    true
96}
97
98fn default_fuzzy_threshold() -> f32 {
99    0.3
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_project_creation() {
108        let project = Project::new("my-project");
109        assert_eq!(project.name, "my-project");
110        assert!(project.description.is_none());
111    }
112
113    #[test]
114    fn test_project_with_description() {
115        let project = Project::new("security-research")
116            .with_description("Security vulnerability findings");
117        assert_eq!(project.description, Some("Security vulnerability findings".to_string()));
118    }
119
120    #[test]
121    fn test_validate_project_name() {
122        assert!(Project::validate_name("my-project"));
123        assert!(Project::validate_name("my_project_123"));
124        assert!(Project::validate_name("MyProject"));
125        assert!(!Project::validate_name(""));
126        assert!(!Project::validate_name("my project")); // space
127        assert!(!Project::validate_name("my.project")); // dot
128    }
129}