use crate::{Entity, Store, StoreError};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct Member {
pub repo_id: String,
pub root: PathBuf,
pub store: Store,
}
#[derive(Debug)]
pub struct System {
pub members: Vec<Member>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MatchKind {
Exact,
Declared,
Inferred,
Ambiguous,
MatchingContract,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StitchKind {
Route,
Topic,
PackageExport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EndRole {
Server,
Client,
Publisher,
Subscriber,
PublisherSubscriber,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StitchEnd {
pub repo_id: String,
pub entity_id: String,
pub sources: Vec<String>,
pub role: Option<EndRole>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stitch {
pub kind: StitchKind,
pub key: String,
pub match_kind: MatchKind,
pub ends: Vec<StitchEnd>,
}
fn topic_sides(store: &Store, topic_id: &str) -> Result<(bool, bool), StoreError> {
let mut publishes = false;
let mut subscribes = false;
for r in store.all_relationships()? {
if r.object != topic_id {
continue;
}
if r.predicate == scc_core::predicates::PUBLISHES {
publishes = true;
} else if r.predicate == scc_core::predicates::SUBSCRIBES {
subscribes = true;
}
}
Ok((publishes, subscribes))
}
impl System {
pub fn open(roots: &[&Path]) -> Result<System, StoreError> {
let mut members = Vec::with_capacity(roots.len());
for root in roots {
let db = root.join(".scc").join("scc.db");
if !db.is_file() {
return Err(StoreError::Corrupt(format!(
"system member not indexed (no {}): run `scc index` there first",
db.display()
)));
}
let store = Store::open(&db, root)?;
members.push(Member {
repo_id: store.repo_id.clone(),
root: store.root.clone(),
store,
});
}
Ok(System { members })
}
pub fn member_entity_ids(&self, repo_id: &str) -> Result<Vec<String>, StoreError> {
let m = self
.members
.iter()
.find(|m| m.repo_id == repo_id)
.ok_or_else(|| StoreError::Corrupt(format!("unknown member {repo_id}")))?;
Ok(m
.store
.all_entities()?
.into_iter()
.map(|e| e.id)
.collect())
}
fn end(&self, m: &Member, e: &Entity, role: Option<EndRole>) -> Result<StitchEnd, StoreError> {
Ok(StitchEnd {
repo_id: m.repo_id.clone(),
entity_id: e.id.clone(),
sources: m.store.entity_sources(&e.id)?,
role,
})
}
pub fn stitch_routes(&self) -> Result<Vec<Stitch>, StoreError> {
let mut by_path: BTreeMap<String, Vec<(usize, String, Entity)>> = BTreeMap::new();
for (i, m) in self.members.iter().enumerate() {
for e in m.store.entities_by_kind(scc_core::kinds::ROUTE)? {
if let Some((verb, path)) = e.name.split_once(' ') {
by_path
.entry(path.to_string())
.or_default()
.push((i, verb.to_string(), e));
}
}
}
let mut out = Vec::new();
for (path, claims) in &by_path {
let repos: std::collections::BTreeSet<&str> = claims
.iter()
.map(|(i, _, _)| self.members[*i].repo_id.as_str())
.collect();
if repos.len() < 2 {
continue; }
let mut verbs: Vec<&str> = claims.iter().map(|(_, v, _)| v.as_str()).collect();
verbs.sort();
verbs.dedup();
let mut stitch_ends = Vec::new();
for (i, _, e) in claims {
stitch_ends.push(self.end(&self.members[*i], e, Some(EndRole::Server))?);
}
stitch_ends.sort_by(|a, b| {
(a.repo_id.clone(), a.entity_id.clone())
.cmp(&(b.repo_id.clone(), b.entity_id.clone()))
});
stitch_ends.dedup_by(|a, b| a.entity_id == b.entity_id);
if verbs.len() == 1 {
out.push(Stitch {
kind: StitchKind::Route,
key: format!("{} {path}", verbs[0]),
match_kind: MatchKind::MatchingContract,
ends: stitch_ends,
});
} else {
out.push(Stitch {
kind: StitchKind::Route,
key: path.clone(),
match_kind: MatchKind::Ambiguous,
ends: stitch_ends,
});
}
}
out.sort_by(|a, b| a.key.cmp(&b.key));
Ok(out)
}
pub fn stitch_topics(&self) -> Result<Vec<Stitch>, StoreError> {
let mut by_name: BTreeMap<String, Vec<(usize, Entity)>> = BTreeMap::new();
for (i, m) in self.members.iter().enumerate() {
for e in m.store.entities_by_kind(scc_core::kinds::TOPIC)? {
by_name.entry(e.name.clone()).or_default().push((i, e));
}
}
let mut out = Vec::new();
for (name, ends) in &by_name {
let mut stitch_ends = Vec::new();
let mut pub_repos: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut sub_repos: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for (i, e) in ends {
let m = &self.members[*i];
let (publishes, subscribes) = topic_sides(&m.store, &e.id)?;
let role = match (publishes, subscribes) {
(true, true) => EndRole::PublisherSubscriber,
(true, false) => EndRole::Publisher,
(false, true) => EndRole::Subscriber,
(false, false) => EndRole::Unknown,
};
if publishes {
pub_repos.insert(m.repo_id.as_str());
}
if subscribes {
sub_repos.insert(m.repo_id.as_str());
}
stitch_ends.push(self.end(m, e, Some(role))?);
}
let repos: std::collections::BTreeSet<&str> =
stitch_ends.iter().map(|e| e.repo_id.as_str()).collect();
if repos.len() < 2 {
continue;
}
stitch_ends.sort_by(|a, b| {
(a.repo_id.clone(), a.entity_id.clone())
.cmp(&(b.repo_id.clone(), b.entity_id.clone()))
});
let directed = !pub_repos.is_empty()
&& !sub_repos.is_empty()
&& !(pub_repos == sub_repos && pub_repos.len() == 1);
let match_kind = if directed {
MatchKind::Exact
} else {
MatchKind::MatchingContract
};
out.push(Stitch {
kind: StitchKind::Topic,
key: name.clone(),
match_kind,
ends: stitch_ends,
});
}
out.sort_by(|a, b| a.key.cmp(&b.key));
Ok(out)
}
pub fn stitch_package_exports(&self) -> Result<Vec<Stitch>, StoreError> {
let mut exports: BTreeMap<String, Vec<(usize, Entity)>> = BTreeMap::new();
for (i, m) in self.members.iter().enumerate() {
for e in m.store.entities_by_kind(scc_core::kinds::EXPORT)? {
exports
.entry(norm_export(&e.name))
.or_default()
.push((i, e));
}
}
let mut imports: Vec<(usize, String, Vec<String>)> = Vec::new();
for (i, m) in self.members.iter().enumerate() {
for (_file, module, names, _line, _typ) in m.store.all_imports()? {
let syms: Vec<String> = names.into_iter().map(|(_, alias)| norm_export(&alias)).collect();
if !syms.is_empty() {
imports.push((i, module, syms));
}
}
}
let mut out = Vec::new();
for (ai, module, names) in &imports {
let am = &self.members[*ai];
for wanted in names {
let Some(providers) = exports.get(wanted) else {
continue;
};
let others: Vec<(usize, Entity)> = providers
.iter()
.filter(|(bi, _)| *bi != *ai)
.cloned()
.collect();
if others.is_empty() {
continue;
}
let declared_here: Vec<(usize, Entity)> = others
.iter()
.filter(|(bi, _)| {
norm_export(&self.members[*bi].repo_id) == norm_export(module)
})
.cloned()
.collect();
let (match_kind, ends_src): (MatchKind, Vec<(usize, Entity)>) =
if others.len() > 1 && declared_here.is_empty() {
(MatchKind::Ambiguous, others)
} else if !declared_here.is_empty() {
(MatchKind::Declared, declared_here)
} else {
(MatchKind::Inferred, others)
};
let mut stitch_ends = Vec::new();
if let Ok(rows) = am.store.all_imports() {
for (file, mod_name, _, _, _) in &rows {
if mod_name == module {
stitch_ends.push(StitchEnd {
repo_id: am.repo_id.clone(),
entity_id: format!(
"repo://{}/external_api/{}",
am.repo_id, module
),
sources: vec![file.clone()],
role: None,
});
break;
}
}
}
for (bi, e) in &ends_src {
stitch_ends.push(self.end(&self.members[*bi], e, None)?);
}
stitch_ends.sort_by(|a, b| {
(a.repo_id.clone(), a.entity_id.clone())
.cmp(&(b.repo_id.clone(), b.entity_id.clone()))
});
out.push(Stitch {
kind: StitchKind::PackageExport,
key: format!("{module}::{wanted}"),
match_kind,
ends: stitch_ends,
});
}
}
out.sort_by(|a, b| a.key.cmp(&b.key));
out.dedup_by(|a, b| a.key == b.key && a.match_kind == b.match_kind);
Ok(out)
}
}
fn norm_export(s: &str) -> String {
s.to_ascii_lowercase().replace('_', "-")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Relationship;
use scc_core::{predicates, Entity, Provenance};
use tempfile::TempDir;
fn member_with(
dir: &TempDir,
name: &str,
routes: &[&str],
topics: &[&str],
exports: &[&str],
imports: &[(String, Vec<(String, String)>)],
) -> PathBuf {
let root = dir.path().join(name);
std::fs::create_dir_all(&root).unwrap();
let db = root.join(".scc").join("scc.db");
std::fs::create_dir_all(db.parent().unwrap()).unwrap();
let store = Store::open(&db, &root).unwrap();
for r in routes {
store
.insert_entity(
&Entity::new(format!("repo://{}/route/{}", store.repo_id, r.replace(' ', "-")), "route", r.to_string()),
&[format!("{name}/app.py")],
)
.unwrap();
}
for t in topics {
store
.insert_entity(
&Entity::new(format!("repo://{}/topic/{t}", store.repo_id), "topic", t.to_string()),
&[format!("{name}/bus.py")],
)
.unwrap();
}
for e in exports {
store
.insert_entity(
&Entity::new(format!("repo://{}/export/{e}", store.repo_id), "export", e.to_string()),
&[format!("{name}/lib.py")],
)
.unwrap();
}
for (module, names) in imports {
store
.insert_imports(&format!("{name}/app.py"), &[(module.clone(), names.clone(), 1, "member".into())])
.unwrap();
let ext = Entity::new(
format!("repo://{}/external_api/{}", store.repo_id, module),
"external_api",
module.clone(),
);
let _ = store.insert_entity(&ext, &[format!("{name}/app.py")]);
}
root
}
fn add_topic_side(root: &std::path::Path, topic: &str, publishes: bool, subscribes: bool) {
let store = Store::open(&root.join(".scc").join("scc.db"), root).unwrap();
let tid = format!("repo://{}/topic/{topic}", store.repo_id);
let sym = Entity::new(
format!("repo://{}/symbol/side.py/fn", store.repo_id),
"symbol",
"fn",
);
store.insert_entity(&sym, &["side.py".into()]).unwrap();
if publishes {
store
.insert_relationship(
&Relationship::new(
"rel:pub".to_string(),
sym.id.clone(),
predicates::PUBLISHES,
tid.clone(),
Provenance::Extracted,
),
"side.py",
)
.unwrap();
}
if subscribes {
store
.insert_relationship(
&Relationship::new(
"rel:sub".to_string(),
sym.id.clone(),
predicates::SUBSCRIBES,
tid.clone(),
Provenance::Extracted,
),
"side.py",
)
.unwrap();
}
}
#[test]
fn shared_verb_and_path_is_a_matching_contract() {
let dir = TempDir::new().unwrap();
let a = member_with(&dir, "svc-a", &["GET /health"], &[], &[], &[]);
let b = member_with(&dir, "svc-b", &["GET /health", "POST /users"], &[], &[], &[]);
let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
let routes = sys.stitch_routes().unwrap();
assert_eq!(routes.len(), 1, "{routes:?}");
assert_eq!(routes[0].key, "GET /health");
assert_eq!(routes[0].match_kind, MatchKind::MatchingContract);
assert_eq!(routes[0].ends.len(), 2);
for e in &routes[0].ends {
assert!(e.entity_id.starts_with("repo://"), "{}", e.entity_id);
assert!(!e.sources.is_empty());
assert_eq!(e.role, Some(EndRole::Server), "{e:?}");
}
}
#[test]
fn same_path_different_verbs_stays_ambiguous() {
let dir = TempDir::new().unwrap();
let a = member_with(&dir, "svc-a", &["GET /health"], &[], &[], &[]);
let b = member_with(&dir, "svc-b", &["POST /health"], &[], &[], &[]);
let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
let routes = sys.stitch_routes().unwrap();
assert_eq!(routes.len(), 1, "{routes:?}");
assert_eq!(routes[0].key, "/health");
assert_eq!(routes[0].match_kind, MatchKind::Ambiguous);
assert_eq!(routes[0].ends.len(), 2);
}
#[test]
fn unindexed_member_is_refused_not_conjured() {
let dir = TempDir::new().unwrap();
let bare = dir.path().join("bare");
std::fs::create_dir_all(&bare).unwrap();
let err = System::open(&[bare.as_path()]).unwrap_err();
assert!(err.to_string().contains("not indexed"), "{err}");
assert!(!bare.join(".scc").exists(), "no side-effect database");
}
#[test]
fn bare_shared_topic_is_a_matching_contract() {
let dir = TempDir::new().unwrap();
let a = member_with(&dir, "prod", &[], &["orders.created"], &[], &[]);
let b = member_with(&dir, "cons", &[], &["orders.created"], &[], &[]);
let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
let topics = sys.stitch_topics().unwrap();
assert_eq!(topics.len(), 1);
assert_eq!(topics[0].match_kind, MatchKind::MatchingContract);
assert_eq!(topics[0].ends.len(), 2);
for e in &topics[0].ends {
assert_eq!(e.role, Some(EndRole::Unknown), "{e:?}");
}
}
#[test]
fn publisher_to_subscriber_is_an_exact_stitch() {
let dir = TempDir::new().unwrap();
let a = member_with(&dir, "prod", &[], &["orders.created"], &[], &[]);
let b = member_with(&dir, "cons", &[], &["orders.created"], &[], &[]);
add_topic_side(a.as_path(), "orders.created", true, false);
add_topic_side(b.as_path(), "orders.created", false, true);
let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
let topics = sys.stitch_topics().unwrap();
assert_eq!(topics.len(), 1);
assert_eq!(topics[0].match_kind, MatchKind::Exact);
assert_eq!(topics[0].ends.len(), 2);
let role_of = |repo: &str| {
topics[0]
.ends
.iter()
.find(|e| e.repo_id.contains(repo))
.unwrap()
.role
};
assert_eq!(role_of("prod"), Some(EndRole::Publisher));
assert_eq!(role_of("cons"), Some(EndRole::Subscriber));
}
#[test]
fn publisher_to_publisher_is_a_matching_contract() {
let dir = TempDir::new().unwrap();
let a = member_with(&dir, "prod-a", &[], &["orders.created"], &[], &[]);
let b = member_with(&dir, "prod-b", &[], &["orders.created"], &[], &[]);
add_topic_side(a.as_path(), "orders.created", true, false);
add_topic_side(b.as_path(), "orders.created", true, false);
let sys = System::open(&[a.as_path(), b.as_path()]).unwrap();
let topics = sys.stitch_topics().unwrap();
assert_eq!(topics.len(), 1);
assert_eq!(topics[0].match_kind, MatchKind::MatchingContract);
}
#[test]
fn named_repo_plus_symbol_is_declared() {
let dir = TempDir::new().unwrap();
let hub = member_with(&dir, "stitch_hub", &[], &[], &["normalize_email"], &[]);
let spoke = member_with(
&dir,
"spoke",
&[],
&[],
&[],
&[("stitch_hub".into(), vec![("normalize_email".into(), "normalize_email".into())])],
);
let sys = System::open(&[hub.as_path(), spoke.as_path()]).unwrap();
let stitches = sys.stitch_package_exports().unwrap();
assert_eq!(stitches.len(), 1, "{stitches:?}");
assert_eq!(stitches[0].match_kind, MatchKind::Declared);
}
#[test]
fn symbol_only_match_is_inferred_not_joined() {
let dir = TempDir::new().unwrap();
let hub = member_with(&dir, "other-lib", &[], &[], &["normalize_email"], &[]);
let spoke = member_with(
&dir,
"spoke",
&[],
&[],
&[],
&[("stitch_hub".into(), vec![("normalize_email".into(), "normalize_email".into())])],
);
let sys = System::open(&[hub.as_path(), spoke.as_path()]).unwrap();
let stitches = sys.stitch_package_exports().unwrap();
assert_eq!(stitches.len(), 1, "{stitches:?}");
assert_eq!(stitches[0].match_kind, MatchKind::Inferred);
}
}