Skip to main content

memory_mcp/
types.rs

1use chrono::{DateTime, Utc};
2use rmcp::schemars;
3use serde::{Deserialize, Serialize};
4use std::{fmt, str::FromStr};
5use uuid::Uuid;
6
7use crate::error::MemoryError;
8
9// ---------------------------------------------------------------------------
10// Name validation
11// ---------------------------------------------------------------------------
12
13/// Validate that a memory name or project name contains only safe characters.
14///
15/// Allowed: alphanumeric, hyphens, underscores, dots, and forward slashes
16/// (for nested paths). Dots may not start a component (no `..`). The name
17/// must not be empty.
18pub fn validate_name(name: &str) -> Result<(), MemoryError> {
19    if name.is_empty() {
20        return Err(MemoryError::InvalidInput {
21            reason: "name must not be empty".to_string(),
22        });
23    }
24
25    let components: Vec<&str> = name.split('/').collect();
26
27    if components.len() > 3 {
28        return Err(MemoryError::InvalidInput {
29            reason: format!("name '{}' exceeds maximum nesting depth of 3", name),
30        });
31    }
32
33    for component in &components {
34        if component.is_empty() {
35            return Err(MemoryError::InvalidInput {
36                reason: format!("name '{}' contains an empty path component", name),
37            });
38        }
39        if component.starts_with('.') {
40            return Err(MemoryError::InvalidInput {
41                reason: format!(
42                    "name '{}' contains a dot-prefixed component '{}'",
43                    name, component
44                ),
45            });
46        }
47        if !component
48            .chars()
49            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
50        {
51            return Err(MemoryError::InvalidInput {
52                reason: format!(
53                    "name '{}' contains disallowed characters in component '{}'",
54                    name, component
55                ),
56            });
57        }
58    }
59
60    Ok(())
61}
62
63/// Validate a git branch name to prevent ref injection.
64///
65/// Rejects names that are empty, contain `..`, start or end with `/` or `.`,
66/// contain consecutive slashes, or include characters that git disallows.
67pub fn validate_branch_name(branch: &str) -> Result<(), MemoryError> {
68    if branch.is_empty() {
69        return Err(MemoryError::InvalidInput {
70            reason: "branch name cannot be empty".into(),
71        });
72    }
73    if branch.contains("..") {
74        return Err(MemoryError::InvalidInput {
75            reason: "branch name cannot contain '..'".into(),
76        });
77    }
78    let invalid_chars = [' ', '~', '^', ':', '?', '*', '[', '\\'];
79    for c in branch.chars() {
80        if c.is_ascii_control() || invalid_chars.contains(&c) {
81            return Err(MemoryError::InvalidInput {
82                reason: format!("branch name contains invalid character '{}'", c),
83            });
84        }
85    }
86    if branch.starts_with('/')
87        || branch.ends_with('/')
88        || branch.ends_with('.')
89        || branch.starts_with('.')
90    {
91        return Err(MemoryError::InvalidInput {
92            reason: "branch name has invalid start/end character".into(),
93        });
94    }
95    if branch.contains("//") {
96        return Err(MemoryError::InvalidInput {
97            reason: "branch name contains consecutive slashes".into(),
98        });
99    }
100    Ok(())
101}
102
103// ---------------------------------------------------------------------------
104// Scope
105// ---------------------------------------------------------------------------
106
107/// Where a memory lives on disk and conceptually.
108///
109/// - `Global`           → `global/`
110/// - `Project(name)`    → `projects/{name}/`
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(tag = "type", content = "name")]
113#[non_exhaustive]
114pub enum Scope {
115    /// Machine-wide memories, stored under `global/`.
116    Global,
117    /// Project-scoped memories, stored under `projects/{name}/`.
118    Project(String),
119}
120
121impl Scope {
122    /// Directory prefix inside the repo root.
123    pub fn dir_prefix(&self) -> String {
124        match self {
125            Scope::Global => "global".to_string(),
126            Scope::Project(name) => format!("projects/{}", name),
127        }
128    }
129}
130
131impl fmt::Display for Scope {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Scope::Global => write!(f, "global"),
135            Scope::Project(name) => write!(f, "project:{}", name),
136        }
137    }
138}
139
140impl FromStr for Scope {
141    type Err = MemoryError;
142
143    /// Parse a scope string:
144    /// - `"global"` → `Scope::Global`
145    /// - `"project:{name}"` → `Scope::Project(name)`
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        if s == "global" {
148            return Ok(Scope::Global);
149        }
150        if let Some(name) = s.strip_prefix("project:") {
151            if name.is_empty() {
152                return Err(MemoryError::InvalidInput {
153                    reason: "project scope requires a non-empty name after 'project:'".to_string(),
154                });
155            }
156            if name.contains('/') {
157                return Err(MemoryError::InvalidInput {
158                    reason: "project name must not contain '/'".to_string(),
159                });
160            }
161            validate_name(name)?;
162            return Ok(Scope::Project(name.to_string()));
163        }
164        Err(MemoryError::InvalidInput {
165            reason: format!(
166                "unrecognised scope '{}'; expected 'global' or 'project:<name>'",
167                s
168            ),
169        })
170    }
171}
172
173// ---------------------------------------------------------------------------
174// MemoryMetadata
175// ---------------------------------------------------------------------------
176
177/// Metadata attached to every [`Memory`].
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct MemoryMetadata {
180    /// Free-form tags for categorisation and filtering.
181    pub tags: Vec<String>,
182    /// Where this memory lives (global or project-scoped).
183    pub scope: Scope,
184    /// When this memory was first created.
185    pub created_at: DateTime<Utc>,
186    /// When this memory was last modified.
187    pub updated_at: DateTime<Utc>,
188    /// Optional hint about where this memory came from (e.g. a tool name).
189    pub source: Option<String>,
190}
191
192impl MemoryMetadata {
193    /// Create new metadata with the current timestamp for both `created_at` and `updated_at`.
194    pub fn new(scope: Scope, tags: Vec<String>, source: Option<String>) -> Self {
195        let now = Utc::now();
196        Self {
197            tags,
198            scope,
199            created_at: now,
200            updated_at: now,
201            source,
202        }
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Memory
208// ---------------------------------------------------------------------------
209
210/// A single memory unit, stored on disk as a markdown file with YAML frontmatter.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Memory {
213    /// Stable UUID for vector-index keying.
214    pub id: String,
215    /// Human-readable name / filename stem.
216    pub name: String,
217    /// Markdown body (no frontmatter).
218    pub content: String,
219    /// Associated metadata (tags, scope, timestamps, source).
220    pub metadata: MemoryMetadata,
221}
222
223impl Memory {
224    /// Create a new memory with a random UUID.
225    pub fn new(name: String, content: String, metadata: MemoryMetadata) -> Self {
226        Self {
227            id: Uuid::new_v4().to_string(),
228            name,
229            content,
230            metadata,
231        }
232    }
233
234    /// Render to the on-disk format: YAML frontmatter + markdown body.
235    ///
236    /// Format:
237    /// ```text
238    /// ---
239    /// <yaml>
240    /// ---
241    ///
242    /// <content>
243    /// ```
244    pub fn to_markdown(&self) -> Result<String, MemoryError> {
245        #[derive(Serialize)]
246        struct Frontmatter<'a> {
247            id: &'a str,
248            name: &'a str,
249            tags: &'a [String],
250            scope: &'a Scope,
251            created_at: &'a DateTime<Utc>,
252            updated_at: &'a DateTime<Utc>,
253            #[serde(skip_serializing_if = "Option::is_none")]
254            source: Option<&'a str>,
255        }
256
257        let fm = Frontmatter {
258            id: &self.id,
259            name: &self.name,
260            tags: &self.metadata.tags,
261            scope: &self.metadata.scope,
262            created_at: &self.metadata.created_at,
263            updated_at: &self.metadata.updated_at,
264            source: self.metadata.source.as_deref(),
265        };
266
267        let yaml = serde_yaml_ng::to_string(&fm)?;
268        Ok(format!("---\n{}---\n\n{}", yaml, self.content))
269    }
270
271    /// Parse from on-disk markdown format.
272    pub fn from_markdown(raw: &str) -> Result<Self, MemoryError> {
273        // Must start with "---\n"
274        let rest = raw
275            .strip_prefix("---\n")
276            .ok_or_else(|| MemoryError::InvalidInput {
277                reason: "missing opening frontmatter delimiter".to_string(),
278            })?;
279
280        // Find the closing "---"
281        let end_marker = rest
282            .find("\n---\n")
283            .ok_or_else(|| MemoryError::InvalidInput {
284                reason: "missing closing frontmatter delimiter".to_string(),
285            })?;
286
287        let yaml_str = &rest[..end_marker];
288        // +5 = "\n---\n".len(); skip optional leading newline in body
289        let body = rest[end_marker + 5..].trim_start_matches('\n');
290
291        #[derive(Deserialize)]
292        struct Frontmatter {
293            id: String,
294            name: String,
295            tags: Vec<String>,
296            scope: Scope,
297            created_at: DateTime<Utc>,
298            updated_at: DateTime<Utc>,
299            source: Option<String>,
300        }
301
302        let fm: Frontmatter = serde_yaml_ng::from_str(yaml_str)?;
303
304        Ok(Memory {
305            id: fm.id,
306            name: fm.name,
307            content: body.to_string(),
308            metadata: MemoryMetadata {
309                tags: fm.tags,
310                scope: fm.scope,
311                created_at: fm.created_at,
312                updated_at: fm.updated_at,
313                source: fm.source,
314            },
315        })
316    }
317}
318
319// ---------------------------------------------------------------------------
320// ScopeFilter — for read-only queries (recall, list)
321// ---------------------------------------------------------------------------
322
323/// Controls which scopes are searched during read-only operations.
324///
325/// This is distinct from [`Scope`], which is a storage target for write
326/// operations. `ScopeFilter` describes which memories are *returned*.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum ScopeFilter {
329    /// Search only global memories.
330    GlobalOnly,
331    /// Search a specific project's memories **and** global memories.
332    ProjectAndGlobal(String),
333    /// Search all scopes.
334    All,
335}
336
337/// Parse a scope string into a [`ScopeFilter`] for use in `recall` and `list`.
338///
339/// | Input | Result |
340/// |---|---|
341/// | `None` | `GlobalOnly` |
342/// | `"global"` | `GlobalOnly` |
343/// | `"project:{name}"` | `ProjectAndGlobal(<name>)` |
344/// | `"all"` | `All` |
345pub fn parse_scope_filter(scope: Option<&str>) -> Result<ScopeFilter, MemoryError> {
346    match scope {
347        None | Some("global") => Ok(ScopeFilter::GlobalOnly),
348        Some("all") => Ok(ScopeFilter::All),
349        Some(s) => {
350            let parsed = s.parse::<Scope>()?;
351            match parsed {
352                Scope::Project(name) => Ok(ScopeFilter::ProjectAndGlobal(name)),
353                // "global" is already handled above; exhaustive match ensures
354                // a compile error if new Scope variants are added.
355                Scope::Global => Ok(ScopeFilter::GlobalOnly),
356            }
357        }
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Helper functions
363// ---------------------------------------------------------------------------
364
365/// Parse an optional scope string. `None` defaults to `Scope::Global`.
366pub fn parse_scope(scope: Option<&str>) -> Result<Scope, MemoryError> {
367    match scope {
368        None => Ok(Scope::Global),
369        Some(s) => s.parse::<Scope>(),
370    }
371}
372
373/// Parse a qualified name of the form `"global/<name>"` or
374/// `"projects/<project>/<name>"` back into a `(Scope, name)` pair.
375pub fn parse_qualified_name(qualified: &str) -> Result<(Scope, String), MemoryError> {
376    if let Some(rest) = qualified.strip_prefix("global/") {
377        validate_name(rest)?;
378        return Ok((Scope::Global, rest.to_string()));
379    }
380    if let Some(rest) = qualified.strip_prefix("projects/") {
381        // rest = "<project>/<memory_name>" (possibly nested)
382        if let Some(slash_pos) = rest.find('/') {
383            let project = &rest[..slash_pos];
384            let name = &rest[slash_pos + 1..];
385            if project.is_empty() || name.is_empty() {
386                return Err(MemoryError::InvalidInput {
387                    reason: format!(
388                        "malformed qualified name '{}': project or memory name is empty",
389                        qualified
390                    ),
391                });
392            }
393            validate_name(project)?;
394            validate_name(name)?;
395            return Ok((Scope::Project(project.to_string()), name.to_string()));
396        }
397        return Err(MemoryError::InvalidInput {
398            reason: format!(
399                "malformed qualified name '{}': missing memory name after project",
400                qualified
401            ),
402        });
403    }
404    Err(MemoryError::InvalidInput {
405        reason: format!(
406            "malformed qualified name '{}': must start with 'global/' or 'projects/'",
407            qualified
408        ),
409    })
410}
411
412// ---------------------------------------------------------------------------
413// Tool argument structs
414// ---------------------------------------------------------------------------
415
416/// Arguments for the `remember` tool — store a new memory.
417#[derive(Debug, Deserialize, schemars::JsonSchema)]
418pub struct RememberArgs {
419    /// The content to store. Markdown is supported.
420    pub content: String,
421    /// Human-readable name for this memory (used as the filename stem).
422    pub name: String,
423    /// Optional list of tags for categorisation.
424    #[serde(default)]
425    pub tags: Vec<String>,
426    /// Scope: 'global' or 'project:{name}'. Defaults to 'global'. Use 'project:{basename-of-your-cwd}' for project-scoped storage.
427    #[serde(default)]
428    pub scope: Option<String>,
429    /// Optional hint about the source of this memory.
430    #[serde(default)]
431    pub source: Option<String>,
432}
433
434/// Arguments for the `recall` tool — semantic search.
435#[derive(Debug, Deserialize, schemars::JsonSchema)]
436pub struct RecallArgs {
437    /// Natural-language query to search for.
438    pub query: String,
439    /// Scope: 'global', 'project:{name}', 'all', or omit for global-only. Use 'project:{basename-of-your-cwd}' to search your current project + global memories. Use 'all' to search across every scope.
440    #[serde(default)]
441    pub scope: Option<String>,
442    /// Maximum number of results to return. Defaults to 5.
443    #[serde(default)]
444    pub limit: Option<usize>,
445}
446
447/// Arguments for the `forget` tool — delete a memory.
448#[derive(Debug, Deserialize, schemars::JsonSchema)]
449pub struct ForgetArgs {
450    /// Exact name of the memory to delete.
451    pub name: String,
452    /// Scope of the memory. Defaults to 'global'. Use 'project:{basename-of-your-cwd}' for project-scoped memories.
453    #[serde(default)]
454    pub scope: Option<String>,
455}
456
457/// Arguments for the `edit` tool — modify an existing memory.
458#[derive(Debug, Deserialize, schemars::JsonSchema)]
459pub struct EditArgs {
460    /// Name of the memory to edit.
461    pub name: String,
462    /// New content (replaces existing). Omit to keep current content.
463    #[serde(default)]
464    pub content: Option<String>,
465    /// New tag list (replaces existing). Omit to keep current tags.
466    #[serde(default)]
467    pub tags: Option<Vec<String>>,
468    /// Scope of the memory. Defaults to 'global'. Use 'project:{basename-of-your-cwd}' for project-scoped memories.
469    #[serde(default)]
470    pub scope: Option<String>,
471}
472
473/// Arguments for the `list` tool — browse stored memories.
474#[derive(Debug, Deserialize, schemars::JsonSchema)]
475pub struct ListArgs {
476    /// Scope: 'global', 'project:{name}', 'all', or omit for global-only. Use 'project:{basename-of-your-cwd}' to list project + global memories. Use 'all' to list everything.
477    #[serde(default)]
478    pub scope: Option<String>,
479}
480
481/// Arguments for the `read` tool — retrieve a specific memory by name.
482#[derive(Debug, Deserialize, schemars::JsonSchema)]
483pub struct ReadArgs {
484    /// Exact name of the memory to read.
485    pub name: String,
486    /// Scope of the memory. Defaults to 'global'. Use 'project:{basename-of-your-cwd}' for project-scoped memories.
487    #[serde(default)]
488    pub scope: Option<String>,
489}
490
491/// Arguments for the `sync` tool — push/pull the git remote.
492#[derive(Debug, Deserialize, schemars::JsonSchema)]
493pub struct SyncArgs {
494    /// If true, pull before pushing. Defaults to true.
495    #[serde(default)]
496    pub pull_first: Option<bool>,
497}
498
499// ---------------------------------------------------------------------------
500// PullResult
501// ---------------------------------------------------------------------------
502
503/// The outcome of a `pull()` operation.
504#[derive(Debug)]
505#[non_exhaustive]
506pub enum PullResult {
507    /// No `origin` remote is configured — running in local-only mode.
508    NoRemote,
509    /// The local branch was already up to date with the remote.
510    UpToDate,
511    /// The remote was ahead and the branch was fast-forwarded.
512    FastForward {
513        /// Commit OID before the fast-forward.
514        old_head: [u8; 20],
515        /// Commit OID after the fast-forward.
516        new_head: [u8; 20],
517    },
518    /// A merge was performed; `conflicts_resolved` counts auto-resolved files.
519    Merged {
520        /// Number of conflicting files that were auto-resolved.
521        conflicts_resolved: usize,
522        /// Commit OID before the merge.
523        old_head: [u8; 20],
524        /// Commit OID after the merge.
525        new_head: [u8; 20],
526    },
527}
528
529// ---------------------------------------------------------------------------
530// ChangedMemories
531// ---------------------------------------------------------------------------
532
533/// Memories that changed between two git commits.
534#[derive(Debug, Default)]
535pub struct ChangedMemories {
536    /// Qualified names (e.g. `"global/foo"`) that were added or modified.
537    pub upserted: Vec<String>,
538    /// Qualified names that were deleted.
539    pub removed: Vec<String>,
540}
541
542impl ChangedMemories {
543    /// Returns `true` if there are no changes.
544    pub fn is_empty(&self) -> bool {
545        self.upserted.is_empty() && self.removed.is_empty()
546    }
547}
548
549// ---------------------------------------------------------------------------
550// ReindexStats
551// ---------------------------------------------------------------------------
552
553/// Statistics from an incremental reindex operation.
554#[derive(Debug, Default)]
555pub struct ReindexStats {
556    /// Number of newly indexed memories.
557    pub added: usize,
558    /// Number of memories whose embeddings were refreshed.
559    pub updated: usize,
560    /// Number of memories removed from the index.
561    pub removed: usize,
562    /// Number of memories that failed to index.
563    pub errors: usize,
564}
565
566// ---------------------------------------------------------------------------
567// AppState
568// ---------------------------------------------------------------------------
569
570use std::sync::Arc;
571
572use crate::{
573    auth::AuthProvider, embedding::EmbeddingBackend, health::HealthRegistry, index::VectorStore,
574    repo::MemoryRepo,
575};
576
577/// Shared application state threaded through the Axum server.
578///
579/// Wrapped in a single outer `Arc` at the call site. `repo` is additionally
580/// wrapped in its own `Arc` so it can be cloned into `spawn_blocking` closures.
581#[non_exhaustive]
582pub struct AppState {
583    /// Git-backed memory repository.
584    pub repo: Arc<MemoryRepo>,
585    /// Backend used to compute text embeddings.
586    pub embedding: Box<dyn EmbeddingBackend>,
587    /// In-memory vector index for semantic search (scope-partitioned).
588    pub index: Box<dyn VectorStore>,
589    /// Authentication provider for API access control.
590    pub auth: AuthProvider,
591    /// Branch name used for push/pull operations (default: "main").
592    pub branch: String,
593    /// Passive health registry — subsystems report here, `/readyz` reads here.
594    pub health: HealthRegistry,
595}
596
597impl AppState {
598    /// Create a new application state from subsystem instances.
599    pub fn new(
600        repo: Arc<MemoryRepo>,
601        branch: String,
602        embedding: Box<dyn EmbeddingBackend>,
603        index: Box<dyn VectorStore>,
604        auth: AuthProvider,
605        health: HealthRegistry,
606    ) -> Self {
607        Self {
608            repo,
609            embedding,
610            index,
611            auth,
612            branch,
613            health,
614        }
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Tests
620// ---------------------------------------------------------------------------
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    fn make_memory() -> Memory {
627        let meta = MemoryMetadata {
628            tags: vec!["test".to_string(), "round-trip".to_string()],
629            scope: Scope::Project("my-project".to_string()),
630            created_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
631            updated_at: DateTime::from_timestamp(1_700_000_100, 0).unwrap(),
632            source: Some("unit-test".to_string()),
633        };
634        Memory {
635            id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
636            name: "test-memory".to_string(),
637            content: "# Hello\n\nThis is a test memory.".to_string(),
638            metadata: meta,
639        }
640    }
641
642    #[test]
643    fn round_trip_markdown() {
644        let original = make_memory();
645        let rendered = original.to_markdown().expect("to_markdown should not fail");
646        let parsed = Memory::from_markdown(&rendered).expect("from_markdown should not fail");
647
648        assert_eq!(original.id, parsed.id);
649        assert_eq!(original.name, parsed.name);
650        assert_eq!(original.content, parsed.content);
651        assert_eq!(original.metadata.tags, parsed.metadata.tags);
652        assert_eq!(original.metadata.scope, parsed.metadata.scope);
653        assert_eq!(
654            original.metadata.created_at.timestamp(),
655            parsed.metadata.created_at.timestamp()
656        );
657        assert_eq!(
658            original.metadata.updated_at.timestamp(),
659            parsed.metadata.updated_at.timestamp()
660        );
661        assert_eq!(original.metadata.source, parsed.metadata.source);
662    }
663
664    #[test]
665    fn round_trip_global_scope() {
666        let meta = MemoryMetadata::new(Scope::Global, vec!["global-tag".to_string()], None);
667        let mem = Memory::new("global-mem".to_string(), "Some content.".to_string(), meta);
668        let rendered = mem.to_markdown().unwrap();
669        let parsed = Memory::from_markdown(&rendered).unwrap();
670
671        assert_eq!(parsed.metadata.scope, Scope::Global);
672        assert_eq!(parsed.metadata.source, None);
673        assert_eq!(parsed.content, "Some content.");
674    }
675
676    #[test]
677    fn round_trip_no_source() {
678        let meta = MemoryMetadata::new(Scope::Project("proj".to_string()), vec![], None);
679        let mem = Memory::new("no-src".to_string(), "Body.".to_string(), meta);
680        let md = mem.to_markdown().unwrap();
681        // source field should not appear in yaml
682        assert!(!md.contains("source:"));
683        let parsed = Memory::from_markdown(&md).unwrap();
684        assert_eq!(parsed.metadata.source, None);
685    }
686
687    #[test]
688    fn from_markdown_missing_frontmatter_fails() {
689        let result = Memory::from_markdown("just plain text");
690        assert!(result.is_err());
691    }
692
693    #[test]
694    fn scope_dir_prefix() {
695        assert_eq!(Scope::Global.dir_prefix(), "global");
696        assert_eq!(
697            Scope::Project("foo".to_string()).dir_prefix(),
698            "projects/foo"
699        );
700    }
701
702    #[test]
703    fn scope_from_str_global() {
704        assert_eq!("global".parse::<Scope>().unwrap(), Scope::Global);
705    }
706
707    #[test]
708    fn scope_from_str_project() {
709        assert_eq!(
710            "project:my-proj".parse::<Scope>().unwrap(),
711            Scope::Project("my-proj".to_string())
712        );
713    }
714
715    #[test]
716    fn scope_from_str_empty_project_name_fails() {
717        assert!("project:".parse::<Scope>().is_err());
718    }
719
720    #[test]
721    fn scope_from_str_unknown_fails() {
722        assert!("unknown".parse::<Scope>().is_err());
723        assert!("PROJECT:foo".parse::<Scope>().is_err());
724    }
725
726    #[test]
727    fn scope_from_str_project_traversal_fails() {
728        assert!("project:../../etc".parse::<Scope>().is_err());
729    }
730
731    // validate_name tests (moved from repo.rs)
732
733    #[test]
734    fn validate_name_accepts_valid() {
735        assert!(validate_name("my-memory").is_ok());
736        assert!(validate_name("some_memory").is_ok());
737        assert!(validate_name("nested/path").is_ok());
738        assert!(validate_name("v1.2.3").is_ok());
739    }
740
741    #[test]
742    fn validate_name_rejects_traversal() {
743        assert!(validate_name("../../etc/passwd").is_err());
744        assert!(validate_name("..").is_err());
745        assert!(validate_name(".hidden").is_err());
746        assert!(validate_name("a/../b").is_err());
747    }
748
749    #[test]
750    fn validate_name_rejects_empty() {
751        assert!(validate_name("").is_err());
752    }
753
754    #[test]
755    fn validate_name_rejects_special_chars() {
756        assert!(validate_name("foo;bar").is_err());
757        assert!(validate_name("foo bar").is_err());
758        assert!(validate_name("foo\0bar").is_err());
759    }
760
761    #[test]
762    fn validate_name_rejects_empty_component() {
763        assert!(validate_name("foo//bar").is_err());
764        assert!(validate_name("/absolute").is_err());
765    }
766
767    // parse_scope tests
768
769    #[test]
770    fn test_parse_scope_none_defaults_global() {
771        assert_eq!(parse_scope(None).unwrap(), Scope::Global);
772    }
773
774    #[test]
775    fn test_parse_scope_some_global() {
776        assert_eq!(parse_scope(Some("global")).unwrap(), Scope::Global);
777    }
778
779    #[test]
780    fn test_parse_scope_some_project() {
781        assert_eq!(
782            parse_scope(Some("project:my-proj")).unwrap(),
783            Scope::Project("my-proj".to_string())
784        );
785    }
786
787    // parse_qualified_name tests
788
789    #[test]
790    fn test_parse_qualified_name_global() {
791        let (scope, name) = parse_qualified_name("global/my-memory").unwrap();
792        assert_eq!(scope, Scope::Global);
793        assert_eq!(name, "my-memory");
794    }
795
796    #[test]
797    fn test_parse_qualified_name_project() {
798        let (scope, name) = parse_qualified_name("projects/my-project/my-memory").unwrap();
799        assert_eq!(scope, Scope::Project("my-project".to_string()));
800        assert_eq!(name, "my-memory");
801    }
802
803    #[test]
804    fn test_parse_qualified_name_nested() {
805        let (scope, name) = parse_qualified_name("projects/my-project/nested/memory").unwrap();
806        assert_eq!(scope, Scope::Project("my-project".to_string()));
807        assert_eq!(name, "nested/memory");
808    }
809
810    // validate_branch_name tests
811
812    #[test]
813    fn validate_branch_name_accepts_valid() {
814        assert!(validate_branch_name("main").is_ok());
815        assert!(validate_branch_name("feature/foo").is_ok());
816        assert!(validate_branch_name("release-1.0").is_ok());
817        assert!(validate_branch_name("a/b/c").is_ok());
818        assert!(validate_branch_name("my-branch_v2").is_ok());
819    }
820
821    #[test]
822    fn validate_branch_name_rejects_empty() {
823        assert!(validate_branch_name("").is_err());
824    }
825
826    #[test]
827    fn validate_branch_name_rejects_dot_dot() {
828        assert!(validate_branch_name("foo..bar").is_err());
829        assert!(validate_branch_name("..").is_err());
830    }
831
832    #[test]
833    fn validate_branch_name_rejects_invalid_chars() {
834        for name in &[
835            "foo bar", "foo~bar", "foo^bar", "foo:bar", "foo?bar", "foo*bar", "foo[bar", "foo\\bar",
836        ] {
837            assert!(
838                validate_branch_name(name).is_err(),
839                "should reject: {}",
840                name
841            );
842        }
843    }
844
845    #[test]
846    fn validate_branch_name_rejects_invalid_start_end() {
847        assert!(validate_branch_name("/foo").is_err());
848        assert!(validate_branch_name("foo/").is_err());
849        assert!(validate_branch_name(".foo").is_err());
850        assert!(validate_branch_name("foo.").is_err());
851    }
852
853    #[test]
854    fn validate_branch_name_rejects_consecutive_slashes() {
855        assert!(validate_branch_name("foo//bar").is_err());
856    }
857
858    // parse_scope_filter tests
859
860    #[test]
861    fn scope_filter_none_defaults_to_global_only() {
862        assert_eq!(parse_scope_filter(None).unwrap(), ScopeFilter::GlobalOnly);
863    }
864
865    #[test]
866    fn scope_filter_global_returns_global_only() {
867        assert_eq!(
868            parse_scope_filter(Some("global")).unwrap(),
869            ScopeFilter::GlobalOnly
870        );
871    }
872
873    #[test]
874    fn scope_filter_project_returns_project_and_global() {
875        assert_eq!(
876            parse_scope_filter(Some("project:my-proj")).unwrap(),
877            ScopeFilter::ProjectAndGlobal("my-proj".to_string()),
878        );
879    }
880
881    #[test]
882    fn scope_filter_all_returns_all() {
883        assert_eq!(parse_scope_filter(Some("all")).unwrap(), ScopeFilter::All);
884    }
885
886    #[test]
887    fn scope_filter_invalid_returns_error() {
888        assert!(parse_scope_filter(Some("bogus")).is_err());
889    }
890}