Skip to main content

scc_store/
system.rs

1//! First-class multi-repo systems (mission §XVI): semantic stitching across
2//! member stores with strong keys and recorded match quality (§XVIII).
3//!
4//! A system never concatenates graphs. Each member keeps its own store and
5//! its own canonical entity ids (stable repository identity, §XIV-XV); the
6//! system only adds cross-repo [`Stitch`] edges bound through contracts
7//! (HTTP verb + normalized route), topics, and public-export identity —
8//! never ordinary static `CALLS`, never bare string similarity.
9//!
10//! Match quality is explicit on every stitch: `Exact` (strong key, all
11//! sides evidenced), `Declared` (importer names a member repo AND the
12//! imported symbol), `Inferred` (symbol match, repo binding unresolved),
13//! `Ambiguous` (key claimed incompatibly — recorded, never joined).
14
15use crate::{Entity, Store, StoreError};
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20/// One member repository: its checkout root and its own index database.
21/// Entity ids inside are the member's canonical ids (§XV).
22#[derive(Debug)]
23// trace:v1 id=impl.scc.store.system.member work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
24pub struct Member {
25    /// Canonical member identity (stable repo id, §XIV).
26    pub repo_id: String,
27    /// Checkout root (provenance path, not identity).
28    pub root: PathBuf,
29    /// Member's own store. Opened normally; never rewritten by stitching.
30    pub store: Store,
31}
32
33/// A stitched system: members plus the cross-repo edges between them.
34#[derive(Debug)]
35// trace:v1 id=impl.scc.store.system work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
36pub struct System {
37    /// Member repositories in open order (deterministic: caller order).
38    pub members: Vec<Member>,
39}
40
41/// How a stitch was evidenced. Never stronger than the keys behind it.
42///
43/// Direction rule: a shared SHAPE (server+server routes, pub+pub topics)
44/// is a [`MatchKind::MatchingContract`], never [`MatchKind::Exact`].
45/// Exact requires evidenced direction across members (publisher in one,
46/// subscriber in another; a declared repo binding) — two servers exposing
47/// the same route may never have called each other.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49// trace:v1 id=impl.crates-scc-store-src-system.match-kind work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
50pub enum MatchKind {
51    /// Direction evidenced across members (publisher↔subscriber,
52    /// declared repo binding). Reachable for routes once HTTP client-call
53    /// evidence exists (HTTP_CLIENT_CALL entities — extractor deferred,
54    /// like deferred FlowEdgeKind detection: documented, not faked).
55    Exact,
56    /// Importer names the member repo AND the imported symbol.
57    Declared,
58    /// Symbol match; which repo provides the module is unresolved.
59    Inferred,
60    /// Same key claimed incompatibly — recorded, never joined.
61    Ambiguous,
62    /// Same contract shape in ≥2 members with no evidenced direction
63    /// (server+server routes, pub+pub or bare topics). A shared
64    /// contract, NOT a proven cross-repo dependency.
65    MatchingContract,
66}
67
68/// What kind of cross-repo binding a stitch represents.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70// trace:v1 id=impl.crates-scc-store-src-system.stitch-kind work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
71pub enum StitchKind {
72    /// Same HTTP verb + normalized route in ≥2 members.
73    Route,
74    /// Same topic name produced/consumed in ≥2 members.
75    Topic,
76    /// One member imports a symbol another member exports.
77    PackageExport,
78}
79
80/// Directional role of one stitch end. Package stitches carry no role:
81/// their direction already reads from ends order (importer first) and the
82/// Declared/Inferred verdicts.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84// trace:v1 id=impl.crates-scc-store-src-system.end-role work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
85pub enum EndRole {
86    /// A server route declaration (stitch_routes ends are always this:
87    /// ROUTE entities are server-side evidence).
88    Server,
89    /// Reserved: an HTTP client call (fetch/axios/client-stub evidence).
90    /// No extractor emits this yet — present so the upgrade path needs no
91    /// schema change when client-call detection lands.
92    Client,
93    /// A topic publisher in this member.
94    Publisher,
95    /// A topic subscriber in this member.
96    Subscriber,
97    /// Both publishes and subscribes in this member.
98    PublisherSubscriber,
99    /// Topic claimed with no pub/sub edges: direction unknown.
100    Unknown,
101}
102
103/// One end of a stitch: the member entity plus where it was observed.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105// trace:v1 id=impl.crates-scc-store-src-system.stitch-end work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
106pub struct StitchEnd {
107    /// Member repo id (semantic scope of the entity).
108    pub repo_id: String,
109    /// Canonical entity id inside the member (§XV: unchanged by stitching).
110    pub entity_id: String,
111    /// Files observing this entity (provenance survives stitching).
112    pub sources: Vec<String>,
113    /// Directional role, if this stitch kind has a direction vocabulary.
114    pub role: Option<EndRole>,
115}
116
117/// A cross-repo semantic edge. Ambiguous stitches carry all claimants and
118/// must not be treated as a join.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120// trace:v1 id=impl.crates-scc-store-src-system.stitch work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
121pub struct Stitch {
122    /// Binding family.
123    pub kind: StitchKind,
124    /// Strong key: route `VERB /path`, topic name, or `module::symbol`.
125    pub key: String,
126    /// Evidenced quality (§XVIII).
127    pub match_kind: MatchKind,
128    /// One end per claiming member, ordered by (repo_id, entity_id).
129    pub ends: Vec<StitchEnd>,
130}
131
132/// Publish/subscribe sides of one topic entity inside one member,
133/// from its PUBLISHES/SUBSCRIBES relationship edges.
134// trace:exempt reason=internal-detail
135fn topic_sides(store: &Store, topic_id: &str) -> Result<(bool, bool), StoreError> {
136    let mut publishes = false;
137    let mut subscribes = false;
138    for r in store.all_relationships()? {
139        if r.object != topic_id {
140            continue;
141        }
142        if r.predicate == scc_core::predicates::PUBLISHES {
143            publishes = true;
144        } else if r.predicate == scc_core::predicates::SUBSCRIBES {
145            subscribes = true;
146        }
147    }
148    Ok((publishes, subscribes))
149}
150
151// trace:exempt reason=internal-detail
152impl System {
153    /// Open a system over member checkout roots. Each member database is
154    /// `<root>/.scc/scc.db` (the default state dir); members must already
155    /// be indexed. Stitching is read-only over member graphs.
156    // trace:v1 id=impl.crates-scc-store-src-system-system.open work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
157    pub fn open(roots: &[&Path]) -> Result<System, StoreError> {
158        let mut members = Vec::with_capacity(roots.len());
159        for root in roots {
160            let db = root.join(".scc").join("scc.db");
161            // Read-only command surface: never conjure an empty database for
162            // an unindexed member (that would silently omit it from stitches).
163            if !db.is_file() {
164                return Err(StoreError::Corrupt(format!(
165                    "system member not indexed (no {}): run `scc index` there first",
166                    db.display()
167                )));
168            }
169            let store = Store::open(&db, root)?;
170            members.push(Member {
171                repo_id: store.repo_id.clone(),
172                root: store.root.clone(),
173                store,
174            });
175        }
176        Ok(System { members })
177    }
178
179    /// Canonical entity ids of one member (standalone-vs-system stability
180    /// reads these through [`System`] without rewriting them).
181    // trace:v1 id=impl.crates-scc-store-src-system-system.member-entity-ids work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
182    pub fn member_entity_ids(&self, repo_id: &str) -> Result<Vec<String>, StoreError> {
183        let m = self
184            .members
185            .iter()
186            .find(|m| m.repo_id == repo_id)
187            .ok_or_else(|| StoreError::Corrupt(format!("unknown member {repo_id}")))?;
188        Ok(m
189            .store
190            .all_entities()?
191            .into_iter()
192            .map(|e| e.id)
193            .collect())
194    }
195
196    // trace:v1 id=impl.crates-scc-store-src-system-system.end work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
197    fn end(&self, m: &Member, e: &Entity, role: Option<EndRole>) -> Result<StitchEnd, StoreError> {
198        Ok(StitchEnd {
199            repo_id: m.repo_id.clone(),
200            entity_id: e.id.clone(),
201            sources: m.store.entity_sources(&e.id)?,
202            role,
203        })
204    }
205
206    /// Stitch HTTP contracts: same `VERB /path` in ≥2 members is a
207    /// contract-SHAPE match. ROUTE entities are server declarations, so two
208    /// members exposing the same route are two servers that may never have
209    /// called each other: [`MatchKind::MatchingContract`], never Exact.
210    /// Same path with different verbs across members is ambiguous:
211    /// recorded with all claimants, never joined.
212    // trace:v1 id=impl.scc.store.system.stitch-routes work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
213    pub fn stitch_routes(&self) -> Result<Vec<Stitch>, StoreError> {
214        // path -> [(member_idx, verb, entity)]
215        let mut by_path: BTreeMap<String, Vec<(usize, String, Entity)>> = BTreeMap::new();
216        for (i, m) in self.members.iter().enumerate() {
217            for e in m.store.entities_by_kind(scc_core::kinds::ROUTE)? {
218                if let Some((verb, path)) = e.name.split_once(' ') {
219                    by_path
220                        .entry(path.to_string())
221                        .or_default()
222                        .push((i, verb.to_string(), e));
223                }
224            }
225        }
226        let mut out = Vec::new();
227        for (path, claims) in &by_path {
228            let repos: std::collections::BTreeSet<&str> = claims
229                .iter()
230                .map(|(i, _, _)| self.members[*i].repo_id.as_str())
231                .collect();
232            if repos.len() < 2 {
233                continue; // single-repo path: internal, not a stitch
234            }
235            let mut verbs: Vec<&str> = claims.iter().map(|(_, v, _)| v.as_str()).collect();
236            verbs.sort();
237            verbs.dedup();
238            let mut stitch_ends = Vec::new();
239            for (i, _, e) in claims {
240                stitch_ends.push(self.end(&self.members[*i], e, Some(EndRole::Server))?);
241            }
242            stitch_ends.sort_by(|a, b| {
243                (a.repo_id.clone(), a.entity_id.clone())
244                    .cmp(&(b.repo_id.clone(), b.entity_id.clone()))
245            });
246            stitch_ends.dedup_by(|a, b| a.entity_id == b.entity_id);
247            if verbs.len() == 1 {
248                out.push(Stitch {
249                    kind: StitchKind::Route,
250                    key: format!("{} {path}", verbs[0]),
251                    match_kind: MatchKind::MatchingContract,
252                    ends: stitch_ends,
253                });
254            } else {
255                // same path, different verbs across members: ambiguous —
256                // recorded with all claimants, never joined.
257                out.push(Stitch {
258                    kind: StitchKind::Route,
259                    key: path.clone(),
260                    match_kind: MatchKind::Ambiguous,
261                    ends: stitch_ends,
262                });
263            }
264        }
265        out.sort_by(|a, b| a.key.cmp(&b.key));
266        Ok(out)
267    }
268
269    /// Stitch event topics: same topic name in ≥2 members. Direction
270    /// decides the verdict from each member's PUBLISHES/SUBSCRIBES edges:
271    /// a publisher in one member and a subscriber in another is an
272    /// [`MatchKind::Exact`] dependency; pub+pub, sub+sub, or unevidenced
273    /// sides are a [`MatchKind::MatchingContract`] shape match. Each end
274    /// carries its side ([`EndRole`]).
275    // trace:v1 id=impl.scc.store.system.stitch-topics work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
276    pub fn stitch_topics(&self) -> Result<Vec<Stitch>, StoreError> {
277        let mut by_name: BTreeMap<String, Vec<(usize, Entity)>> = BTreeMap::new();
278        for (i, m) in self.members.iter().enumerate() {
279            for e in m.store.entities_by_kind(scc_core::kinds::TOPIC)? {
280                by_name.entry(e.name.clone()).or_default().push((i, e));
281            }
282        }
283        let mut out = Vec::new();
284        for (name, ends) in &by_name {
285            let mut stitch_ends = Vec::new();
286            let mut pub_repos: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
287            let mut sub_repos: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
288            for (i, e) in ends {
289                let m = &self.members[*i];
290                let (publishes, subscribes) = topic_sides(&m.store, &e.id)?;
291                let role = match (publishes, subscribes) {
292                    (true, true) => EndRole::PublisherSubscriber,
293                    (true, false) => EndRole::Publisher,
294                    (false, true) => EndRole::Subscriber,
295                    (false, false) => EndRole::Unknown,
296                };
297                if publishes {
298                    pub_repos.insert(m.repo_id.as_str());
299                }
300                if subscribes {
301                    sub_repos.insert(m.repo_id.as_str());
302                }
303                stitch_ends.push(self.end(m, e, Some(role))?);
304            }
305            let repos: std::collections::BTreeSet<&str> =
306                stitch_ends.iter().map(|e| e.repo_id.as_str()).collect();
307            if repos.len() < 2 {
308                continue;
309            }
310            stitch_ends.sort_by(|a, b| {
311                (a.repo_id.clone(), a.entity_id.clone())
312                    .cmp(&(b.repo_id.clone(), b.entity_id.clone()))
313            });
314            // Exact only across members: a publisher in one member and a
315            // subscriber in a DIFFERENT one. Same-member pub+sub proves
316            // nothing about the other member.
317            let directed = !pub_repos.is_empty()
318                && !sub_repos.is_empty()
319                && !(pub_repos == sub_repos && pub_repos.len() == 1);
320            let match_kind = if directed {
321                MatchKind::Exact
322            } else {
323                MatchKind::MatchingContract
324            };
325            out.push(Stitch {
326                kind: StitchKind::Topic,
327                key: name.clone(),
328                match_kind,
329                ends: stitch_ends,
330            });
331        }
332        out.sort_by(|a, b| a.key.cmp(&b.key));
333        Ok(out)
334    }
335
336    /// Stitch public API: member A imports module M with symbol names N
337    /// while member B exports n ∈ N. When M names B's repo (normalized),
338    /// the repo binding is declared; otherwise the symbol match is
339    /// inferred and the module→repo binding stays unresolved. A symbol
340    /// exported by ≥2 members is ambiguous.
341    // trace:v1 id=impl.scc.store.system.stitch-package-exports work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
342    pub fn stitch_package_exports(&self) -> Result<Vec<Stitch>, StoreError> {
343        let mut exports: BTreeMap<String, Vec<(usize, Entity)>> = BTreeMap::new();
344        for (i, m) in self.members.iter().enumerate() {
345            for e in m.store.entities_by_kind(scc_core::kinds::EXPORT)? {
346                exports
347                    .entry(norm_export(&e.name))
348                    .or_default()
349                    .push((i, e));
350            }
351        }
352        let mut imports: Vec<(usize, String, Vec<String>)> = Vec::new();
353        for (i, m) in self.members.iter().enumerate() {
354            for (_file, module, names, _line, _typ) in m.store.all_imports()? {
355                let syms: Vec<String> = names.into_iter().map(|(_, alias)| norm_export(&alias)).collect();
356                if !syms.is_empty() {
357                    imports.push((i, module, syms));
358                }
359            }
360        }
361        let mut out = Vec::new();
362        for (ai, module, names) in &imports {
363            let am = &self.members[*ai];
364            for wanted in names {
365                let Some(providers) = exports.get(wanted) else {
366                    continue;
367                };
368                let others: Vec<(usize, Entity)> = providers
369                    .iter()
370                    .filter(|(bi, _)| *bi != *ai)
371                    .cloned()
372                    .collect();
373                if others.is_empty() {
374                    continue;
375                }
376                let declared_here: Vec<(usize, Entity)> = others
377                    .iter()
378                    .filter(|(bi, _)| {
379                        norm_export(&self.members[*bi].repo_id) == norm_export(module)
380                    })
381                    .cloned()
382                    .collect();
383                let (match_kind, ends_src): (MatchKind, Vec<(usize, Entity)>) =
384                    if others.len() > 1 && declared_here.is_empty() {
385                        (MatchKind::Ambiguous, others)
386                    } else if !declared_here.is_empty() {
387                        (MatchKind::Declared, declared_here)
388                    } else {
389                        (MatchKind::Inferred, others)
390                    };
391                // importer end: the importing file. The external_api edge
392                // target is not always materialized as an entity row, so
393                // provenance comes from the imports table itself.
394                let mut stitch_ends = Vec::new();
395                if let Ok(rows) = am.store.all_imports() {
396                    for (file, mod_name, _, _, _) in &rows {
397                        if mod_name == module {
398                            stitch_ends.push(StitchEnd {
399                                repo_id: am.repo_id.clone(),
400                                entity_id: format!(
401                                    "repo://{}/external_api/{}",
402                                    am.repo_id, module
403                                ),
404                                sources: vec![file.clone()],
405                                role: None,
406                            });
407                            break;
408                        }
409                    }
410                }
411                for (bi, e) in &ends_src {
412                    stitch_ends.push(self.end(&self.members[*bi], e, None)?);
413                }
414                stitch_ends.sort_by(|a, b| {
415                    (a.repo_id.clone(), a.entity_id.clone())
416                        .cmp(&(b.repo_id.clone(), b.entity_id.clone()))
417                });
418                out.push(Stitch {
419                    kind: StitchKind::PackageExport,
420                    key: format!("{module}::{wanted}"),
421                    match_kind,
422                    ends: stitch_ends,
423                });
424            }
425        }
426        out.sort_by(|a, b| a.key.cmp(&b.key));
427        out.dedup_by(|a, b| a.key == b.key && a.match_kind == b.match_kind);
428        Ok(out)
429    }
430}
431
432/// Normalize an export/symbol/module name for cross-repo comparison:
433/// lowercase, `-`/`_` unified.
434// trace:exempt reason=internal-detail
435fn norm_export(s: &str) -> String {
436    s.to_ascii_lowercase().replace('_', "-")
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::Relationship;
443    use scc_core::{predicates, Entity, Provenance};
444    use tempfile::TempDir;
445
446    // trace:exempt reason=internal-detail
447    fn member_with(
448        dir: &TempDir,
449        name: &str,
450        routes: &[&str],
451        topics: &[&str],
452        exports: &[&str],
453        imports: &[(String, Vec<(String, String)>)],
454    ) -> PathBuf {
455        let root = dir.path().join(name);
456        std::fs::create_dir_all(&root).unwrap();
457        let db = root.join(".scc").join("scc.db");
458        std::fs::create_dir_all(db.parent().unwrap()).unwrap();
459        let store = Store::open(&db, &root).unwrap();
460        for r in routes {
461            store
462                .insert_entity(
463                    &Entity::new(format!("repo://{}/route/{}", store.repo_id, r.replace(' ', "-")), "route", r.to_string()),
464                    &[format!("{name}/app.py")],
465                )
466                .unwrap();
467        }
468        for t in topics {
469            store
470                .insert_entity(
471                    &Entity::new(format!("repo://{}/topic/{t}", store.repo_id), "topic", t.to_string()),
472                    &[format!("{name}/bus.py")],
473                )
474                .unwrap();
475        }
476        for e in exports {
477            store
478                .insert_entity(
479                    &Entity::new(format!("repo://{}/export/{e}", store.repo_id), "export", e.to_string()),
480                    &[format!("{name}/lib.py")],
481                )
482                .unwrap();
483        }
484        for (module, names) in imports {
485            store
486                .insert_imports(&format!("{name}/app.py"), &[(module.clone(), names.clone(), 1, "member".into())])
487                .unwrap();
488            // unresolved modules surface as external_api, like the extractor
489            let ext = Entity::new(
490                format!("repo://{}/external_api/{}", store.repo_id, module),
491                "external_api",
492                module.clone(),
493            );
494            let _ = store.insert_entity(&ext, &[format!("{name}/app.py")]);
495        }
496        root
497    }
498
499    // trace:exempt reason=internal-detail
500    fn add_topic_side(root: &std::path::Path, topic: &str, publishes: bool, subscribes: bool) {
501        let store = Store::open(&root.join(".scc").join("scc.db"), root).unwrap();
502        let tid = format!("repo://{}/topic/{topic}", store.repo_id);
503        let sym = Entity::new(
504            format!("repo://{}/symbol/side.py/fn", store.repo_id),
505            "symbol",
506            "fn",
507        );
508        store.insert_entity(&sym, &["side.py".into()]).unwrap();
509        if publishes {
510            store
511                .insert_relationship(
512                    &Relationship::new(
513                        "rel:pub".to_string(),
514                        sym.id.clone(),
515                        predicates::PUBLISHES,
516                        tid.clone(),
517                        Provenance::Extracted,
518                    ),
519                    "side.py",
520                )
521                .unwrap();
522        }
523        if subscribes {
524            store
525                .insert_relationship(
526                    &Relationship::new(
527                        "rel:sub".to_string(),
528                        sym.id.clone(),
529                        predicates::SUBSCRIBES,
530                        tid.clone(),
531                        Provenance::Extracted,
532                    ),
533                    "side.py",
534                )
535                .unwrap();
536        }
537    }
538
539    #[test]
540    // trace:v1 id=test.scc.store.system-route-shape verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-routes
541    fn shared_verb_and_path_is_a_matching_contract() {
542        // Two servers exposing GET /health may never have called each
543        // other: a MatchingContract shape match, never Exact.
544        let dir = TempDir::new().unwrap();
545        let a = member_with(&dir, "svc-a", &["GET /health"], &[], &[], &[]);
546        let b = member_with(&dir, "svc-b", &["GET /health", "POST /users"], &[], &[], &[]);
547        let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
548        let routes = sys.stitch_routes().unwrap();
549        assert_eq!(routes.len(), 1, "{routes:?}");
550        assert_eq!(routes[0].key, "GET /health");
551        assert_eq!(routes[0].match_kind, MatchKind::MatchingContract);
552        assert_eq!(routes[0].ends.len(), 2);
553        // canonical ids untouched by stitching; ends are server-declared
554        for e in &routes[0].ends {
555            assert!(e.entity_id.starts_with("repo://"), "{}", e.entity_id);
556            assert!(!e.sources.is_empty());
557            assert_eq!(e.role, Some(EndRole::Server), "{e:?}");
558        }
559    }
560
561    #[test]
562    // trace:v1 id=test.scc.store.system-route-ambiguous verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-routes
563    fn same_path_different_verbs_stays_ambiguous() {
564        let dir = TempDir::new().unwrap();
565        let a = member_with(&dir, "svc-a", &["GET /health"], &[], &[], &[]);
566        let b = member_with(&dir, "svc-b", &["POST /health"], &[], &[], &[]);
567        let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
568        let routes = sys.stitch_routes().unwrap();
569        assert_eq!(routes.len(), 1, "{routes:?}");
570        assert_eq!(routes[0].key, "/health");
571        assert_eq!(routes[0].match_kind, MatchKind::Ambiguous);
572        assert_eq!(routes[0].ends.len(), 2);
573    }
574
575    #[test]
576    // trace:v1 id=test.scc.store.system-unindexed-refused verifies=REQ-SI-503JSBGP exercises=impl.crates-scc-store-src-system-system.open
577    fn unindexed_member_is_refused_not_conjured() {
578        let dir = TempDir::new().unwrap();
579        let bare = dir.path().join("bare");
580        std::fs::create_dir_all(&bare).unwrap();
581        let err = System::open(&[bare.as_path()]).unwrap_err();
582        assert!(err.to_string().contains("not indexed"), "{err}");
583        assert!(!bare.join(".scc").exists(), "no side-effect database");
584    }
585
586    #[test]
587    // trace:v1 id=test.scc.store.system-topic-bare verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-topics
588    fn bare_shared_topic_is_a_matching_contract() {
589        // Same name, no pub/sub edges: direction unknown, not Exact.
590        let dir = TempDir::new().unwrap();
591        let a = member_with(&dir, "prod", &[], &["orders.created"], &[], &[]);
592        let b = member_with(&dir, "cons", &[], &["orders.created"], &[], &[]);
593        let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
594        let topics = sys.stitch_topics().unwrap();
595        assert_eq!(topics.len(), 1);
596        assert_eq!(topics[0].match_kind, MatchKind::MatchingContract);
597        assert_eq!(topics[0].ends.len(), 2);
598        for e in &topics[0].ends {
599            assert_eq!(e.role, Some(EndRole::Unknown), "{e:?}");
600        }
601    }
602
603    #[test]
604    // trace:v1 id=test.scc.store.system-topic-directed verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-topics
605    fn publisher_to_subscriber_is_an_exact_stitch() {
606        // Publisher in one member, subscriber in another: evidenced
607        // direction, with per-end roles.
608        let dir = TempDir::new().unwrap();
609        let a = member_with(&dir, "prod", &[], &["orders.created"], &[], &[]);
610        let b = member_with(&dir, "cons", &[], &["orders.created"], &[], &[]);
611        add_topic_side(a.as_path(), "orders.created", true, false);
612        add_topic_side(b.as_path(), "orders.created", false, true);
613        let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
614        let topics = sys.stitch_topics().unwrap();
615        assert_eq!(topics.len(), 1);
616        assert_eq!(topics[0].match_kind, MatchKind::Exact);
617        assert_eq!(topics[0].ends.len(), 2);
618        let role_of = |repo: &str| {
619            topics[0]
620                .ends
621                .iter()
622                .find(|e| e.repo_id.contains(repo))
623                .unwrap()
624                .role
625        };
626        assert_eq!(role_of("prod"), Some(EndRole::Publisher));
627        assert_eq!(role_of("cons"), Some(EndRole::Subscriber));
628    }
629
630    #[test]
631    // trace:v1 id=test.scc.store.system-topic-undirected verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-topics
632    fn publisher_to_publisher_is_a_matching_contract() {
633        // Both sides publish: no cross-member direction evidenced.
634        let dir = TempDir::new().unwrap();
635        let a = member_with(&dir, "prod-a", &[], &["orders.created"], &[], &[]);
636        let b = member_with(&dir, "prod-b", &[], &["orders.created"], &[], &[]);
637        add_topic_side(a.as_path(), "orders.created", true, false);
638        add_topic_side(b.as_path(), "orders.created", true, false);
639        let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
640        let topics = sys.stitch_topics().unwrap();
641        assert_eq!(topics.len(), 1);
642        assert_eq!(topics[0].match_kind, MatchKind::MatchingContract);
643    }
644
645    #[test]
646    // trace:v1 id=test.scc.store.system-package-declared verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-package-exports
647    fn named_repo_plus_symbol_is_declared() {
648        let dir = TempDir::new().unwrap();
649        let hub = member_with(&dir, "stitch_hub", &[], &[], &["normalize_email"], &[]);
650        let spoke = member_with(
651            &dir,
652            "spoke",
653            &[],
654            &[],
655            &[],
656            &[("stitch_hub".into(), vec![("normalize_email".into(), "normalize_email".into())])],
657        );
658        let sys = System::open(&[hub.as_path(), spoke.as_path()]).unwrap();
659        let stitches = sys.stitch_package_exports().unwrap();
660        assert_eq!(stitches.len(), 1, "{stitches:?}");
661        assert_eq!(stitches[0].match_kind, MatchKind::Declared);
662    }
663
664    #[test]
665    // trace:v1 id=test.scc.store.system-package-inferred verifies=REQ-SI-503JSBGP exercises=impl.scc.store.system.stitch-package-exports
666    fn symbol_only_match_is_inferred_not_joined() {
667        let dir = TempDir::new().unwrap();
668        let hub = member_with(&dir, "other-lib", &[], &[], &["normalize_email"], &[]);
669        let spoke = member_with(
670            &dir,
671            "spoke",
672            &[],
673            &[],
674            &[],
675            &[("stitch_hub".into(), vec![("normalize_email".into(), "normalize_email".into())])],
676        );
677        let sys = System::open(&[hub.as_path(), spoke.as_path()]).unwrap();
678        let stitches = sys.stitch_package_exports().unwrap();
679        assert_eq!(stitches.len(), 1, "{stitches:?}");
680        assert_eq!(stitches[0].match_kind, MatchKind::Inferred);
681    }
682}