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 namespace string using the same rules as daemon ID components.
281///
282/// Used for user-provided namespaces that arrive on their own (e.g. the
283/// `--namespace` CLI flag) rather than as part of a full daemon ID.
284pub fn validate_namespace(namespace: &str) -> Result<()> {
285    validate_component(namespace, "namespace")
286}
287
288/// Validates a single component (namespace or name) of a daemon ID.
289fn validate_component(s: &str, component_name: &str) -> Result<()> {
290    if s.is_empty() {
291        return Err(DaemonIdError::EmptyComponent {
292            component: component_name.to_string(),
293        }
294        .into());
295    }
296    if s.contains('/') {
297        return Err(DaemonIdError::PathSeparator {
298            id: s.to_string(),
299            sep: '/',
300        }
301        .into());
302    }
303    if s.contains('\\') {
304        return Err(DaemonIdError::PathSeparator {
305            id: s.to_string(),
306            sep: '\\',
307        }
308        .into());
309    }
310    if s.contains("..") {
311        return Err(DaemonIdError::ParentDirRef { id: s.to_string() }.into());
312    }
313    if s.contains("--") {
314        return Err(DaemonIdError::ReservedSequence { id: s.to_string() }.into());
315    }
316    if s.starts_with('-') || s.ends_with('-') {
317        return Err(DaemonIdError::LeadingTrailingDash { id: s.to_string() }.into());
318    }
319    if s.contains(' ') {
320        return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
321    }
322    if s == "." {
323        return Err(DaemonIdError::CurrentDir.into());
324    }
325    if !s
326        .chars()
327        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
328    {
329        return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
330    }
331    Ok(())
332}
333
334/// Validates a qualified daemon ID string.
335fn validate_qualified_id(s: &str) -> Result<()> {
336    if s.is_empty() {
337        return Err(DaemonIdError::Empty.into());
338    }
339    if s.contains('\\') {
340        return Err(DaemonIdError::PathSeparator {
341            id: s.to_string(),
342            sep: '\\',
343        }
344        .into());
345    }
346    if s.contains(' ') {
347        return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
348    }
349    if !s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
350        return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
351    }
352
353    // Check slash count
354    let slash_count = s.chars().filter(|&c| c == '/').count();
355    if slash_count == 0 {
356        return Err(DaemonIdError::MissingNamespace { id: s.to_string() }.into());
357    }
358    if slash_count > 1 {
359        return Err(DaemonIdError::PathSeparator {
360            id: s.to_string(),
361            sep: '/',
362        }
363        .into());
364    }
365
366    // Check both parts are non-empty
367    let (ns, name) = s.split_once('/').unwrap();
368    if ns.is_empty() || name.is_empty() {
369        return Err(DaemonIdError::PathSeparator {
370            id: s.to_string(),
371            sep: '/',
372        }
373        .into());
374    }
375
376    // Validate each component individually
377    // This ensures parse("./api") fails just like try_new(".", "api")
378    validate_component(ns, "namespace")?;
379    validate_component(name, "name")?;
380
381    Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_daemon_id_new() {
390        let id = DaemonId::new("global", "api");
391        assert_eq!(id.namespace(), "global");
392        assert_eq!(id.name(), "api");
393        assert_eq!(id.qualified(), "global/api");
394        assert_eq!(id.safe_path(), "global--api");
395    }
396
397    #[test]
398    fn test_daemon_id_parse() {
399        let id = DaemonId::parse("project-a/api").unwrap();
400        assert_eq!(id.namespace(), "project-a");
401        assert_eq!(id.name(), "api");
402
403        // Missing namespace should fail
404        assert!(DaemonId::parse("api").is_err());
405
406        // Empty parts should fail
407        assert!(DaemonId::parse("/api").is_err());
408        assert!(DaemonId::parse("project/").is_err());
409
410        // Multiple slashes should fail
411        assert!(DaemonId::parse("a/b/c").is_err());
412    }
413
414    #[test]
415    fn test_daemon_id_from_safe_path() {
416        let id = DaemonId::from_safe_path("project-a--api").unwrap();
417        assert_eq!(id.namespace(), "project-a");
418        assert_eq!(id.name(), "api");
419
420        // No separator should fail
421        assert!(DaemonId::from_safe_path("projectapi").is_err());
422    }
423
424    #[test]
425    fn test_daemon_id_roundtrip() {
426        let original = DaemonId::new("my-project", "my-daemon");
427        let safe = original.safe_path();
428        let recovered = DaemonId::from_safe_path(&safe).unwrap();
429        assert_eq!(original, recovered);
430    }
431
432    #[test]
433    fn test_daemon_id_display() {
434        let id = DaemonId::new("global", "api");
435        assert_eq!(format!("{id}"), "global/api");
436    }
437
438    #[test]
439    fn test_daemon_id_serialize() {
440        let id = DaemonId::new("global", "api");
441        let json = serde_json::to_string(&id).unwrap();
442        assert_eq!(json, "\"global/api\"");
443
444        let deserialized: DaemonId = serde_json::from_str(&json).unwrap();
445        assert_eq!(id, deserialized);
446    }
447
448    #[test]
449    fn test_daemon_id_validation() {
450        // Valid IDs
451        assert!(DaemonId::try_new("global", "api").is_ok());
452        assert!(DaemonId::try_new("my-project", "my-daemon").is_ok());
453        assert!(DaemonId::try_new("project_a", "daemon_1").is_ok());
454
455        // Invalid - contains reserved sequences
456        assert!(DaemonId::try_new("my--project", "api").is_err());
457        assert!(DaemonId::try_new("project", "my--daemon").is_err());
458
459        // Invalid - contains path separators
460        assert!(DaemonId::try_new("my/project", "api").is_err());
461        assert!(DaemonId::try_new("project", "my/daemon").is_err());
462
463        // Invalid - empty
464        assert!(DaemonId::try_new("", "api").is_err());
465        assert!(DaemonId::try_new("project", "").is_err());
466    }
467
468    #[test]
469    fn test_daemon_id_ordering() {
470        let id1 = DaemonId::new("a", "x");
471        let id2 = DaemonId::new("a", "y");
472        let id3 = DaemonId::new("b", "x");
473
474        assert!(id1 < id2);
475        assert!(id2 < id3);
476        assert!(id1 < id3);
477    }
478
479    // Edge case tests for from_safe_path
480    #[test]
481    fn test_from_safe_path_double_dash_in_namespace_rejected() {
482        // Namespaces containing "--" are rejected to preserve the Serialize <->
483        // Deserialize roundtrip: qualified() output must always be re-parseable.
484        // namespace_from_path() already sanitizes "--" -> "-" before reaching here.
485        assert!(DaemonId::from_safe_path("my--project--api").is_err());
486        assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
487    }
488
489    #[test]
490    fn test_from_safe_path_roundtrip_via_qualified() {
491        // Standard case - single "--" separator, full roundtrip via qualified()
492        let id = DaemonId::from_safe_path("global--api").unwrap();
493        assert_eq!(id.namespace(), "global");
494        assert_eq!(id.name(), "api");
495        // Must roundtrip through qualified format (Serialize <-> Deserialize)
496        let recovered = DaemonId::parse(&id.qualified()).unwrap();
497        assert_eq!(recovered, id);
498    }
499
500    #[test]
501    fn test_from_safe_path_no_separator() {
502        // No "--" at all - should fail
503        assert!(DaemonId::from_safe_path("globalapi").is_err());
504        assert!(DaemonId::from_safe_path("api").is_err());
505    }
506
507    #[test]
508    fn test_from_safe_path_empty_parts() {
509        // Empty namespace (starts with --) - should fail validation
510        let result = DaemonId::from_safe_path("--api");
511        assert!(result.is_err());
512
513        // Empty name (ends with --) - should fail validation
514        let result = DaemonId::from_safe_path("namespace--");
515        assert!(result.is_err());
516    }
517
518    // Cross-namespace dependency parsing tests
519    #[test]
520    fn test_parse_cross_namespace_dependency() {
521        // Can parse fully qualified dependency reference
522        let id = DaemonId::parse("other-project/postgres").unwrap();
523        assert_eq!(id.namespace(), "other-project");
524        assert_eq!(id.name(), "postgres");
525    }
526
527    // Test for directory names containing -- (namespace sanitization)
528    #[test]
529    fn test_directory_with_double_dash_in_name() {
530        // Directory names like "my--project" are invalid for try_new because -- is reserved
531        let result = DaemonId::try_new("my--project", "api");
532        assert!(result.is_err());
533
534        // from_safe_path also rejects "--" in namespace to preserve Serialize <->
535        // Deserialize roundtrip. namespace_from_path() sanitizes "--" to "-" before
536        // writing to the filesystem, so this case never arises in practice.
537        let result = DaemonId::from_safe_path("my--project--api");
538        assert!(
539            result.is_err(),
540            "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
541        );
542    }
543
544    #[test]
545    fn test_parse_dot_namespace_rejected() {
546        // parse("./api") should fail because "." is invalid as namespace
547        // This ensures consistency with try_new(".", "api") which also fails
548        let result = DaemonId::parse("./api");
549        assert!(result.is_err());
550
551        // Also test ".." as namespace
552        let result = DaemonId::parse("../api");
553        assert!(result.is_err());
554    }
555
556    // Serialization roundtrip tests
557    #[test]
558    fn test_daemon_id_toml_roundtrip() {
559        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
560        struct TestConfig {
561            daemon_id: DaemonId,
562        }
563
564        let config = TestConfig {
565            daemon_id: DaemonId::new("my-project", "api"),
566        };
567
568        let toml_str = toml::to_string(&config).unwrap();
569        assert!(toml_str.contains("daemon_id = \"my-project/api\""));
570
571        let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
572        assert_eq!(config, recovered);
573    }
574
575    #[test]
576    fn test_daemon_id_json_roundtrip_in_map() {
577        use std::collections::HashMap;
578
579        let mut map: HashMap<String, DaemonId> = HashMap::new();
580        map.insert("primary".to_string(), DaemonId::new("global", "api"));
581        map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
582
583        let json = serde_json::to_string(&map).unwrap();
584        let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
585        assert_eq!(map, recovered);
586    }
587
588    // Pitchfork special ID test
589    #[test]
590    fn test_pitchfork_id() {
591        let id = DaemonId::pitchfork();
592        assert_eq!(id.namespace(), "global");
593        assert_eq!(id.name(), "pitchfork");
594        assert_eq!(id.qualified(), "global/pitchfork");
595    }
596
597    // Unicode and special character tests
598    #[test]
599    fn test_daemon_id_rejects_unicode() {
600        assert!(DaemonId::try_new("プロジェクト", "api").is_err());
601        assert!(DaemonId::try_new("project", "工作者").is_err());
602    }
603
604    #[test]
605    fn test_daemon_id_rejects_control_chars() {
606        assert!(DaemonId::try_new("project\x00", "api").is_err());
607        assert!(DaemonId::try_new("project", "api\x1b").is_err());
608    }
609
610    #[test]
611    fn test_daemon_id_rejects_spaces() {
612        assert!(DaemonId::try_new("my project", "api").is_err());
613        assert!(DaemonId::try_new("project", "my api").is_err());
614        assert!(DaemonId::parse("my project/api").is_err());
615    }
616
617    #[test]
618    fn test_daemon_id_rejects_chars_outside_schema_pattern() {
619        // Schema only allows [A-Za-z0-9_.-] for each component.
620        assert!(DaemonId::try_new("project+alpha", "api").is_err());
621        assert!(DaemonId::try_new("project", "api@v1").is_err());
622    }
623
624    #[test]
625    fn test_daemon_id_rejects_leading_trailing_dash() {
626        // Leading dash in namespace or name
627        assert!(DaemonId::try_new("-project", "api").is_err());
628        assert!(DaemonId::try_new("project", "-api").is_err());
629        // Trailing dash in namespace or name
630        assert!(DaemonId::try_new("project-", "api").is_err());
631        assert!(DaemonId::try_new("project", "api-").is_err());
632        // Verify the safe_path roundtrip invariant holds for names with internal dashes
633        let id = DaemonId::try_new("a", "b").unwrap();
634        let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
635        assert_eq!(id, recovered);
636        // from_safe_path must also reject names produced by invalid components
637        assert!(DaemonId::from_safe_path("a---b").is_err()); // came from "a-"/"b" or "a"/"-b"
638    }
639
640    #[test]
641    fn test_daemon_id_rejects_parent_dir_traversal() {
642        assert!(DaemonId::try_new("project", "..").is_err());
643        assert!(DaemonId::try_new("..", "api").is_err());
644        assert!(DaemonId::parse("../api").is_err());
645        assert!(DaemonId::parse("project/..").is_err());
646    }
647
648    #[test]
649    fn test_daemon_id_rejects_current_dir() {
650        assert!(DaemonId::try_new(".", "api").is_err());
651        assert!(DaemonId::try_new("project", ".").is_err());
652    }
653
654    #[test]
655    fn test_validate_namespace() {
656        assert!(validate_namespace("frontend").is_ok());
657        assert!(validate_namespace("global").is_ok());
658        assert!(validate_namespace("my-project").is_ok());
659        // Same rules as daemon ID components
660        assert!(validate_namespace("").is_err());
661        assert!(validate_namespace("my--ns").is_err());
662        assert!(validate_namespace("a/b").is_err());
663        assert!(validate_namespace("has space").is_err());
664        assert!(validate_namespace("-lead").is_err());
665    }
666
667    // Hash and equality tests for HashMap usage
668    #[test]
669    fn test_daemon_id_hash_consistency() {
670        use std::collections::HashSet;
671
672        let id1 = DaemonId::new("project", "api");
673        let id2 = DaemonId::new("project", "api");
674        let id3 = DaemonId::parse("project/api").unwrap();
675
676        let mut set = HashSet::new();
677        set.insert(id1.clone());
678
679        // Same ID constructed differently should be found
680        assert!(set.contains(&id2));
681        assert!(set.contains(&id3));
682
683        // Verify they're all equal
684        assert_eq!(id1, id2);
685        assert_eq!(id2, id3);
686    }
687}