Skip to main content

pitchfork_cli/
daemon_id.rs

1//! Structured daemon ID type that separates namespace and name.
2//!
3//! This module provides a type-safe representation of daemon IDs that
4//! eliminates the need for repeated parsing and formatting operations.
5
6use crate::Result;
7use crate::error::DaemonIdError;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt::{self, Display};
10use std::hash::Hash;
11
12/// A structured daemon identifier consisting of a namespace and a name.
13///
14/// All daemons have a namespace - global daemons use "global" as their namespace.
15/// This type eliminates the need to repeatedly parse and format daemon IDs.
16///
17/// # Formats
18///
19/// - **Qualified format**: `namespace/name` (e.g., `project-a/api`, `global/web`)
20/// - **Safe path format**: `namespace--name` (for filesystem paths)
21///
22/// # Examples
23///
24/// ```
25/// use pitchfork_cli::daemon_id::DaemonId;
26///
27/// let id = DaemonId::try_new("project-a", "api").unwrap();
28/// assert_eq!(id.namespace(), "project-a");
29/// assert_eq!(id.name(), "api");
30/// assert_eq!(id.qualified(), "project-a/api");
31/// assert_eq!(id.safe_path(), "project-a--api");
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct DaemonId {
35    namespace: String,
36    name: String,
37}
38
39impl Default for DaemonId {
40    fn default() -> Self {
41        Self {
42            namespace: "global".to_string(),
43            name: "unknown".to_string(),
44        }
45    }
46}
47
48impl DaemonId {
49    /// Creates a new DaemonId from namespace and name.
50    ///
51    /// # Panics
52    ///
53    /// Panics if either the namespace or name is invalid (contains invalid characters,
54    /// is empty, contains `--`, etc.). Use `try_new()` for a non-panicking version.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use pitchfork_cli::daemon_id::DaemonId;
60    ///
61    /// let id = DaemonId::new("global", "api");
62    /// ```
63    #[cfg(test)]
64    pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
65        let namespace = namespace.into();
66        let name = name.into();
67
68        // Validate inputs - panic on invalid values
69        if let Err(e) = validate_component(&namespace, "namespace") {
70            panic!("Invalid namespace '{namespace}': {e}");
71        }
72        if let Err(e) = validate_component(&name, "name") {
73            panic!("Invalid name '{name}': {e}");
74        }
75
76        Self { namespace, name }
77    }
78
79    /// Creates a new DaemonId without validation.
80    ///
81    /// # Safety
82    ///
83    /// This function does not validate the inputs. Use it only when you are certain
84    /// the namespace and name are valid (e.g., when reading from a trusted source
85    /// like a parsed safe_path with "--" in the namespace component).
86    ///
87    /// For user-provided input, use `new()` or `try_new()` instead.
88    pub(crate) fn new_unchecked(namespace: impl Into<String>, name: impl Into<String>) -> Self {
89        Self {
90            namespace: namespace.into(),
91            name: name.into(),
92        }
93    }
94
95    /// Creates a new DaemonId with validation.
96    ///
97    /// Returns an error if either the namespace or name is invalid.
98    pub fn try_new(namespace: impl Into<String>, name: impl Into<String>) -> Result<Self> {
99        let namespace = namespace.into();
100        let name = name.into();
101
102        validate_component(&namespace, "namespace")?;
103        validate_component(&name, "name")?;
104
105        Ok(Self { namespace, name })
106    }
107
108    /// Parses a qualified daemon ID string into a DaemonId.
109    ///
110    /// The input must be in the format `namespace/name`.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use pitchfork_cli::daemon_id::DaemonId;
116    ///
117    /// let id = DaemonId::parse("project-a/api").unwrap();
118    /// assert_eq!(id.namespace(), "project-a");
119    /// assert_eq!(id.name(), "api");
120    /// ```
121    pub fn parse(s: &str) -> Result<Self> {
122        validate_qualified_id(s)?;
123
124        // validate_qualified_id ensures exactly one '/' is present, so this unwrap is safe.
125        let (ns, name) = s
126            .split_once('/')
127            .expect("validate_qualified_id ensures '/' is present");
128        Ok(Self {
129            namespace: ns.to_string(),
130            name: name.to_string(),
131        })
132    }
133
134    /// Creates a DaemonId from a filesystem-safe path component.
135    ///
136    /// Converts `namespace--name` format back to a DaemonId.
137    /// Both components are validated with the same rules as `try_new()`,
138    /// ensuring that the result can always be serialized and deserialized
139    /// through the qualified (`namespace/name`) format without error.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use pitchfork_cli::daemon_id::DaemonId;
145    ///
146    /// let id = DaemonId::from_safe_path("project-a--api").unwrap();
147    /// assert_eq!(id.qualified(), "project-a/api");
148    /// assert_eq!(DaemonId::parse(&id.qualified()).unwrap(), id);
149    ///
150    /// // Empty namespace or name fails validation
151    /// assert!(DaemonId::from_safe_path("--api").is_err());
152    /// assert!(DaemonId::from_safe_path("namespace--").is_err());
153    /// // Namespace containing "--" is rejected to preserve roundtrip
154    /// assert!(DaemonId::from_safe_path("my--project--api").is_err());
155    /// ```
156    pub fn from_safe_path(s: &str) -> Result<Self> {
157        if let Some((ns, name)) = s.split_once("--") {
158            // Validate both components with the same rules as try_new().
159            // This guarantees that qualified() output can always be re-parsed,
160            // preserving the Serialize <-> Deserialize roundtrip contract.
161            validate_component(ns, "namespace")?;
162            validate_component(name, "name")?;
163            Ok(Self {
164                namespace: ns.to_string(),
165                name: name.to_string(),
166            })
167        } else {
168            Err(DaemonIdError::InvalidSafePath {
169                path: s.to_string(),
170            }
171            .into())
172        }
173    }
174
175    /// Returns the namespace portion of the daemon ID.
176    pub fn namespace(&self) -> &str {
177        &self.namespace
178    }
179
180    /// Returns a DaemonId for the pitchfork supervisor itself.
181    ///
182    /// This is a convenience method to avoid repeated `DaemonId::new("global", "pitchfork")` calls.
183    pub fn pitchfork() -> Self {
184        // Use new_unchecked for this constant value to avoid redundant validation
185        Self::new_unchecked("global", "pitchfork")
186    }
187
188    /// Returns the name (short ID) portion of the daemon ID.
189    pub fn name(&self) -> &str {
190        &self.name
191    }
192
193    /// Returns the qualified format: `namespace/name`.
194    pub fn qualified(&self) -> String {
195        format!("{}/{}", self.namespace, self.name)
196    }
197
198    /// Returns the filesystem-safe format: `namespace--name`.
199    pub fn safe_path(&self) -> String {
200        format!("{}--{}", self.namespace, self.name)
201    }
202
203    /// Returns the main log file path for this daemon.
204    pub fn log_path(&self) -> std::path::PathBuf {
205        let safe = self.safe_path();
206        crate::env::PITCHFORK_LOGS_DIR
207            .join(&safe)
208            .join(format!("{safe}.log"))
209    }
210
211    /// Returns the qualified format with dim namespace for terminal output (stdout).
212    ///
213    /// Format: `<dim>namespace</dim>/name`
214    pub fn styled_qualified(&self) -> String {
215        use crate::ui::style::ndim;
216        format!("{}/{}", ndim(&self.namespace), self.name)
217    }
218}
219
220impl Display for DaemonId {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        write!(f, "{}/{}", self.namespace, self.name)
223    }
224}
225
226// NOTE: AsRef<str> and Borrow<str> implementations were intentionally removed.
227// The Borrow trait has a contract that if T: Borrow<U>, then T's Hash/Eq/Ord
228// must be consistent with U's. DaemonId derives Hash and Eq on both namespace
229// and name, so implementing Borrow<str> would violate this contract and cause
230// HashMap/HashSet lookups via &str to silently break due to hash mismatches.
231
232/// Serialize as qualified string "namespace/name"
233impl Serialize for DaemonId {
234    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
235    where
236        S: Serializer,
237    {
238        serializer.serialize_str(&self.qualified())
239    }
240}
241
242/// Deserialize from qualified string "namespace/name"
243impl<'de> Deserialize<'de> for DaemonId {
244    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
245    where
246        D: Deserializer<'de>,
247    {
248        let s = String::deserialize(deserializer)?;
249        DaemonId::parse(&s).map_err(serde::de::Error::custom)
250    }
251}
252
253/// JSON Schema implementation for DaemonId
254///
255/// In `pitchfork.toml`, users write **short names** (e.g. `api`) for daemon
256/// keys under `[daemons]` and for same-namespace `depends` entries.  Fully
257/// qualified `namespace/name` format is only required for cross-namespace
258/// dependency references.  The pattern therefore accepts both forms.
259impl schemars::JsonSchema for DaemonId {
260    fn schema_name() -> std::borrow::Cow<'static, str> {
261        "DaemonId".into()
262    }
263
264    fn schema_id() -> std::borrow::Cow<'static, str> {
265        concat!(module_path!(), "::DaemonId").into()
266    }
267
268    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
269        schemars::json_schema!({
270            "type": "string",
271            "description": "Daemon name (e.g. 'api') or qualified ID ('namespace/name') for cross-namespace references",
272            "pattern": r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$",
273            "not": {
274                "pattern": r"\.\.|--|(^|/)-|-($|/)|^\.$|^\./|/\.$"
275            }
276        })
277    }
278}
279
280/// Validates a single component (namespace or name) of a daemon ID.
281fn validate_component(s: &str, component_name: &str) -> Result<()> {
282    if s.is_empty() {
283        return Err(DaemonIdError::EmptyComponent {
284            component: component_name.to_string(),
285        }
286        .into());
287    }
288    if s.contains('/') {
289        return Err(DaemonIdError::PathSeparator {
290            id: s.to_string(),
291            sep: '/',
292        }
293        .into());
294    }
295    if s.contains('\\') {
296        return Err(DaemonIdError::PathSeparator {
297            id: s.to_string(),
298            sep: '\\',
299        }
300        .into());
301    }
302    if s.contains("..") {
303        return Err(DaemonIdError::ParentDirRef { id: s.to_string() }.into());
304    }
305    if s.contains("--") {
306        return Err(DaemonIdError::ReservedSequence { id: s.to_string() }.into());
307    }
308    if s.starts_with('-') || s.ends_with('-') {
309        return Err(DaemonIdError::LeadingTrailingDash { id: s.to_string() }.into());
310    }
311    if s.contains(' ') {
312        return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
313    }
314    if s == "." {
315        return Err(DaemonIdError::CurrentDir.into());
316    }
317    if !s
318        .chars()
319        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
320    {
321        return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
322    }
323    Ok(())
324}
325
326/// Validates a qualified daemon ID string.
327fn validate_qualified_id(s: &str) -> Result<()> {
328    if s.is_empty() {
329        return Err(DaemonIdError::Empty.into());
330    }
331    if s.contains('\\') {
332        return Err(DaemonIdError::PathSeparator {
333            id: s.to_string(),
334            sep: '\\',
335        }
336        .into());
337    }
338    if s.contains(' ') {
339        return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
340    }
341    if !s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
342        return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
343    }
344
345    // Check slash count
346    let slash_count = s.chars().filter(|&c| c == '/').count();
347    if slash_count == 0 {
348        return Err(DaemonIdError::MissingNamespace { id: s.to_string() }.into());
349    }
350    if slash_count > 1 {
351        return Err(DaemonIdError::PathSeparator {
352            id: s.to_string(),
353            sep: '/',
354        }
355        .into());
356    }
357
358    // Check both parts are non-empty
359    let (ns, name) = s.split_once('/').unwrap();
360    if ns.is_empty() || name.is_empty() {
361        return Err(DaemonIdError::PathSeparator {
362            id: s.to_string(),
363            sep: '/',
364        }
365        .into());
366    }
367
368    // Validate each component individually
369    // This ensures parse("./api") fails just like try_new(".", "api")
370    validate_component(ns, "namespace")?;
371    validate_component(name, "name")?;
372
373    Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_daemon_id_new() {
382        let id = DaemonId::new("global", "api");
383        assert_eq!(id.namespace(), "global");
384        assert_eq!(id.name(), "api");
385        assert_eq!(id.qualified(), "global/api");
386        assert_eq!(id.safe_path(), "global--api");
387    }
388
389    #[test]
390    fn test_daemon_id_parse() {
391        let id = DaemonId::parse("project-a/api").unwrap();
392        assert_eq!(id.namespace(), "project-a");
393        assert_eq!(id.name(), "api");
394
395        // Missing namespace should fail
396        assert!(DaemonId::parse("api").is_err());
397
398        // Empty parts should fail
399        assert!(DaemonId::parse("/api").is_err());
400        assert!(DaemonId::parse("project/").is_err());
401
402        // Multiple slashes should fail
403        assert!(DaemonId::parse("a/b/c").is_err());
404    }
405
406    #[test]
407    fn test_daemon_id_from_safe_path() {
408        let id = DaemonId::from_safe_path("project-a--api").unwrap();
409        assert_eq!(id.namespace(), "project-a");
410        assert_eq!(id.name(), "api");
411
412        // No separator should fail
413        assert!(DaemonId::from_safe_path("projectapi").is_err());
414    }
415
416    #[test]
417    fn test_daemon_id_roundtrip() {
418        let original = DaemonId::new("my-project", "my-daemon");
419        let safe = original.safe_path();
420        let recovered = DaemonId::from_safe_path(&safe).unwrap();
421        assert_eq!(original, recovered);
422    }
423
424    #[test]
425    fn test_daemon_id_display() {
426        let id = DaemonId::new("global", "api");
427        assert_eq!(format!("{id}"), "global/api");
428    }
429
430    #[test]
431    fn test_daemon_id_serialize() {
432        let id = DaemonId::new("global", "api");
433        let json = serde_json::to_string(&id).unwrap();
434        assert_eq!(json, "\"global/api\"");
435
436        let deserialized: DaemonId = serde_json::from_str(&json).unwrap();
437        assert_eq!(id, deserialized);
438    }
439
440    #[test]
441    fn test_daemon_id_validation() {
442        // Valid IDs
443        assert!(DaemonId::try_new("global", "api").is_ok());
444        assert!(DaemonId::try_new("my-project", "my-daemon").is_ok());
445        assert!(DaemonId::try_new("project_a", "daemon_1").is_ok());
446
447        // Invalid - contains reserved sequences
448        assert!(DaemonId::try_new("my--project", "api").is_err());
449        assert!(DaemonId::try_new("project", "my--daemon").is_err());
450
451        // Invalid - contains path separators
452        assert!(DaemonId::try_new("my/project", "api").is_err());
453        assert!(DaemonId::try_new("project", "my/daemon").is_err());
454
455        // Invalid - empty
456        assert!(DaemonId::try_new("", "api").is_err());
457        assert!(DaemonId::try_new("project", "").is_err());
458    }
459
460    #[test]
461    fn test_daemon_id_ordering() {
462        let id1 = DaemonId::new("a", "x");
463        let id2 = DaemonId::new("a", "y");
464        let id3 = DaemonId::new("b", "x");
465
466        assert!(id1 < id2);
467        assert!(id2 < id3);
468        assert!(id1 < id3);
469    }
470
471    // Edge case tests for from_safe_path
472    #[test]
473    fn test_from_safe_path_double_dash_in_namespace_rejected() {
474        // Namespaces containing "--" are rejected to preserve the Serialize <->
475        // Deserialize roundtrip: qualified() output must always be re-parseable.
476        // namespace_from_path() already sanitizes "--" -> "-" before reaching here.
477        assert!(DaemonId::from_safe_path("my--project--api").is_err());
478        assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
479    }
480
481    #[test]
482    fn test_from_safe_path_roundtrip_via_qualified() {
483        // Standard case - single "--" separator, full roundtrip via qualified()
484        let id = DaemonId::from_safe_path("global--api").unwrap();
485        assert_eq!(id.namespace(), "global");
486        assert_eq!(id.name(), "api");
487        // Must roundtrip through qualified format (Serialize <-> Deserialize)
488        let recovered = DaemonId::parse(&id.qualified()).unwrap();
489        assert_eq!(recovered, id);
490    }
491
492    #[test]
493    fn test_from_safe_path_no_separator() {
494        // No "--" at all - should fail
495        assert!(DaemonId::from_safe_path("globalapi").is_err());
496        assert!(DaemonId::from_safe_path("api").is_err());
497    }
498
499    #[test]
500    fn test_from_safe_path_empty_parts() {
501        // Empty namespace (starts with --) - should fail validation
502        let result = DaemonId::from_safe_path("--api");
503        assert!(result.is_err());
504
505        // Empty name (ends with --) - should fail validation
506        let result = DaemonId::from_safe_path("namespace--");
507        assert!(result.is_err());
508    }
509
510    // Cross-namespace dependency parsing tests
511    #[test]
512    fn test_parse_cross_namespace_dependency() {
513        // Can parse fully qualified dependency reference
514        let id = DaemonId::parse("other-project/postgres").unwrap();
515        assert_eq!(id.namespace(), "other-project");
516        assert_eq!(id.name(), "postgres");
517    }
518
519    // Test for directory names containing -- (namespace sanitization)
520    #[test]
521    fn test_directory_with_double_dash_in_name() {
522        // Directory names like "my--project" are invalid for try_new because -- is reserved
523        let result = DaemonId::try_new("my--project", "api");
524        assert!(result.is_err());
525
526        // from_safe_path also rejects "--" in namespace to preserve Serialize <->
527        // Deserialize roundtrip. namespace_from_path() sanitizes "--" to "-" before
528        // writing to the filesystem, so this case never arises in practice.
529        let result = DaemonId::from_safe_path("my--project--api");
530        assert!(
531            result.is_err(),
532            "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
533        );
534    }
535
536    #[test]
537    fn test_parse_dot_namespace_rejected() {
538        // parse("./api") should fail because "." is invalid as namespace
539        // This ensures consistency with try_new(".", "api") which also fails
540        let result = DaemonId::parse("./api");
541        assert!(result.is_err());
542
543        // Also test ".." as namespace
544        let result = DaemonId::parse("../api");
545        assert!(result.is_err());
546    }
547
548    // Serialization roundtrip tests
549    #[test]
550    fn test_daemon_id_toml_roundtrip() {
551        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
552        struct TestConfig {
553            daemon_id: DaemonId,
554        }
555
556        let config = TestConfig {
557            daemon_id: DaemonId::new("my-project", "api"),
558        };
559
560        let toml_str = toml::to_string(&config).unwrap();
561        assert!(toml_str.contains("daemon_id = \"my-project/api\""));
562
563        let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
564        assert_eq!(config, recovered);
565    }
566
567    #[test]
568    fn test_daemon_id_json_roundtrip_in_map() {
569        use std::collections::HashMap;
570
571        let mut map: HashMap<String, DaemonId> = HashMap::new();
572        map.insert("primary".to_string(), DaemonId::new("global", "api"));
573        map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
574
575        let json = serde_json::to_string(&map).unwrap();
576        let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
577        assert_eq!(map, recovered);
578    }
579
580    // Pitchfork special ID test
581    #[test]
582    fn test_pitchfork_id() {
583        let id = DaemonId::pitchfork();
584        assert_eq!(id.namespace(), "global");
585        assert_eq!(id.name(), "pitchfork");
586        assert_eq!(id.qualified(), "global/pitchfork");
587    }
588
589    // Unicode and special character tests
590    #[test]
591    fn test_daemon_id_rejects_unicode() {
592        assert!(DaemonId::try_new("プロジェクト", "api").is_err());
593        assert!(DaemonId::try_new("project", "工作者").is_err());
594    }
595
596    #[test]
597    fn test_daemon_id_rejects_control_chars() {
598        assert!(DaemonId::try_new("project\x00", "api").is_err());
599        assert!(DaemonId::try_new("project", "api\x1b").is_err());
600    }
601
602    #[test]
603    fn test_daemon_id_rejects_spaces() {
604        assert!(DaemonId::try_new("my project", "api").is_err());
605        assert!(DaemonId::try_new("project", "my api").is_err());
606        assert!(DaemonId::parse("my project/api").is_err());
607    }
608
609    #[test]
610    fn test_daemon_id_rejects_chars_outside_schema_pattern() {
611        // Schema only allows [A-Za-z0-9_.-] for each component.
612        assert!(DaemonId::try_new("project+alpha", "api").is_err());
613        assert!(DaemonId::try_new("project", "api@v1").is_err());
614    }
615
616    #[test]
617    fn test_daemon_id_rejects_leading_trailing_dash() {
618        // Leading dash in namespace or name
619        assert!(DaemonId::try_new("-project", "api").is_err());
620        assert!(DaemonId::try_new("project", "-api").is_err());
621        // Trailing dash in namespace or name
622        assert!(DaemonId::try_new("project-", "api").is_err());
623        assert!(DaemonId::try_new("project", "api-").is_err());
624        // Verify the safe_path roundtrip invariant holds for names with internal dashes
625        let id = DaemonId::try_new("a", "b").unwrap();
626        let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
627        assert_eq!(id, recovered);
628        // from_safe_path must also reject names produced by invalid components
629        assert!(DaemonId::from_safe_path("a---b").is_err()); // came from "a-"/"b" or "a"/"-b"
630    }
631
632    #[test]
633    fn test_daemon_id_rejects_parent_dir_traversal() {
634        assert!(DaemonId::try_new("project", "..").is_err());
635        assert!(DaemonId::try_new("..", "api").is_err());
636        assert!(DaemonId::parse("../api").is_err());
637        assert!(DaemonId::parse("project/..").is_err());
638    }
639
640    #[test]
641    fn test_daemon_id_rejects_current_dir() {
642        assert!(DaemonId::try_new(".", "api").is_err());
643        assert!(DaemonId::try_new("project", ".").is_err());
644    }
645
646    // Hash and equality tests for HashMap usage
647    #[test]
648    fn test_daemon_id_hash_consistency() {
649        use std::collections::HashSet;
650
651        let id1 = DaemonId::new("project", "api");
652        let id2 = DaemonId::new("project", "api");
653        let id3 = DaemonId::parse("project/api").unwrap();
654
655        let mut set = HashSet::new();
656        set.insert(id1.clone());
657
658        // Same ID constructed differently should be found
659        assert!(set.contains(&id2));
660        assert!(set.contains(&id3));
661
662        // Verify they're all equal
663        assert_eq!(id1, id2);
664        assert_eq!(id2, id3);
665    }
666}