1use crate::{Entity, Store, StoreError};
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug)]
23pub struct Member {
25 pub repo_id: String,
27 pub root: PathBuf,
29 pub store: Store,
31}
32
33#[derive(Debug)]
35pub struct System {
37 pub members: Vec<Member>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum MatchKind {
51 Exact,
56 Declared,
58 Inferred,
60 Ambiguous,
62 MatchingContract,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum StitchKind {
72 Route,
74 Topic,
76 PackageExport,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum EndRole {
86 Server,
89 Client,
93 Publisher,
95 Subscriber,
97 PublisherSubscriber,
99 Unknown,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct StitchEnd {
107 pub repo_id: String,
109 pub entity_id: String,
111 pub sources: Vec<String>,
113 pub role: Option<EndRole>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct Stitch {
122 pub kind: StitchKind,
124 pub key: String,
126 pub match_kind: MatchKind,
128 pub ends: Vec<StitchEnd>,
130}
131
132fn 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
151impl System {
153 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 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 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 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 pub fn stitch_routes(&self) -> Result<Vec<Stitch>, StoreError> {
214 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; }
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 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 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 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 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 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
432fn 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 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 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 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 fn shared_verb_and_path_is_a_matching_contract() {
542 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 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 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 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 fn bare_shared_topic_is_a_matching_contract() {
589 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 fn publisher_to_subscriber_is_an_exact_stitch() {
606 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 fn publisher_to_publisher_is_a_matching_contract() {
633 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 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 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}