Skip to main content

ijima_core/
repo.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! The Context Mapper — a global registry mapping repo names to filesystem
5//! paths, remote URLs, and roles (the "canonical Anima ecosystem member
6//! list").
7//!
8//! This solves the harness directory-context problem: when a harness (Pi,
9//! Wallace, OpenCode) starts in a working directory, it reverse-resolves the
10//! path to a canonical repo identity via Ijima, then loads that repo's mined
11//! context. It also completes the path ↔ `project` link that memories and
12//! sessions already key on.
13//!
14//! **Identity model:** the *stable* identity is the remote URL (repos get
15//! cloned/moved — that's the problem being solved); `path` is a mutable
16//! *current location*. `name` is the human PK.
17
18/// A registered repository: name ↔ location ↔ identity ↔ role.
19///
20/// The registry is **global** (not namespace-scoped) — it is the canonical
21/// ecosystem roster, admin-managed, readable by any authenticated principal.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub struct RepoDirectory {
25    /// Canonical name (primary key), e.g. `Ijima`, `Kagome`, `Tsume`.
26    pub name: String,
27    /// Current filesystem location (mutable). Normalized without a trailing
28    /// slash on write.
29    pub path: String,
30    /// Stable identity: the `git remote` URL. More durable than `path`.
31    #[cfg_attr(
32        feature = "serde",
33        serde(default, skip_serializing_if = "Option::is_none")
34    )]
35    pub remote_url: Option<String>,
36    /// Ecological role, e.g. `tui-substrate`, `gateway-adapter`, `orchestrator`.
37    #[cfg_attr(
38        feature = "serde",
39        serde(default, skip_serializing_if = "Option::is_none")
40    )]
41    pub role: Option<String>,
42    /// Whether this repo is part of the Anima ecosystem (the roster filter).
43    #[cfg_attr(feature = "serde", serde(default = "default_anima_member"))]
44    pub is_anima_member: bool,
45}
46
47#[cfg(feature = "serde")]
48fn default_anima_member() -> bool {
49    true
50}
51
52impl RepoDirectory {
53    /// Constructs a repo, normalizing the path (no trailing slash).
54    pub fn new(name: impl Into<String>, path: impl Into<String>) -> Self {
55        Self {
56            name: name.into(),
57            path: normalize_path(&path.into()),
58            remote_url: None,
59            role: None,
60            is_anima_member: true,
61        }
62    }
63
64    /// Sets the remote URL (stable identity).
65    #[must_use]
66    pub fn with_remote(mut self, url: impl Into<String>) -> Self {
67        self.remote_url = Some(url.into());
68        self
69    }
70
71    /// Sets the ecological role.
72    #[must_use]
73    pub fn with_role(mut self, role: impl Into<String>) -> Self {
74        self.role = Some(role.into());
75        self
76    }
77}
78
79/// Normalizes a filesystem path: no trailing slash (except root `/`).
80pub fn normalize_path(path: &str) -> String {
81    let p = path.trim();
82    if p.len() > 1 {
83        p.trim_end_matches('/').to_string()
84    } else {
85        p.to_string()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn new_normalizes_trailing_slash() {
95        let r = RepoDirectory::new("Ijima", "/home/x/Ijima/");
96        assert_eq!(r.path, "/home/x/Ijima");
97        assert!(r.is_anima_member);
98    }
99
100    #[test]
101    fn builders_set_optional_fields() {
102        let r = RepoDirectory::new("Kagome", "/k")
103            .with_remote("git@github.com:Anima/Kagome.git")
104            .with_role("hardware");
105        assert_eq!(
106            r.remote_url.as_deref(),
107            Some("git@github.com:Anima/Kagome.git")
108        );
109        assert_eq!(r.role.as_deref(), Some("hardware"));
110    }
111
112    #[test]
113    fn root_path_preserved() {
114        assert_eq!(normalize_path("/"), "/");
115    }
116}