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}