Skip to main content

ternary_constellation/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Constellation pattern for grouping related ternary crates into deployable units.
4//!
5//! A `Constellation` is a named group of skills (crates) with declared dependencies.
6//! `ConstellationBuilder` constructs them, `ConstellationResolver` resolves dependencies
7//! and detects conflicts, `ConstellationCompiler` bundles them for deployment targets,
8//! and `ConstellationRegistry` catalogs constellations across a fleet. Maps to the
9//! "room type" concept — a Codespace room loads a specific constellation.
10
11use std::collections::{HashMap, HashSet};
12
13// ── SkillDescriptor ────────────────────────────────────────────────────────
14
15/// A skill (crate) that can be part of a constellation.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SkillDescriptor {
18    pub name: String,
19    pub version: String,
20    pub dependencies: Vec<String>,
21}
22
23impl SkillDescriptor {
24    pub fn new(name: &str, version: &str) -> Self {
25        Self {
26            name: name.to_string(),
27            version: version.to_string(),
28            dependencies: Vec::new(),
29        }
30    }
31
32    pub fn with_dependency(mut self, dep: &str) -> Self {
33        self.dependencies.push(dep.to_string());
34        self
35    }
36
37    pub fn with_dependencies(mut self, deps: &[&str]) -> Self {
38        self.dependencies = deps.iter().map(|s| s.to_string()).collect();
39        self
40    }
41}
42
43// ── Constellation ──────────────────────────────────────────────────────────
44
45/// A named group of skills with metadata.
46#[derive(Debug, Clone)]
47pub struct Constellation {
48    pub name: String,
49    pub version: String,
50    pub description: String,
51    pub skills: Vec<SkillDescriptor>,
52}
53
54impl Constellation {
55    pub fn new(name: &str) -> Self {
56        Self {
57            name: name.to_string(),
58            version: "0.1.0".to_string(),
59            description: String::new(),
60            skills: Vec::new(),
61        }
62    }
63
64    /// Get all skill names in this constellation.
65    pub fn skill_names(&self) -> Vec<&str> {
66        self.skills.iter().map(|s| s.name.as_str()).collect()
67    }
68
69    /// Check if a skill is included.
70    pub fn has_skill(&self, name: &str) -> bool {
71        self.skills.iter().any(|s| s.name == name)
72    }
73
74    /// Get the total dependency count (unique).
75    pub fn unique_dependency_count(&self) -> usize {
76        let mut deps = HashSet::new();
77        for skill in &self.skills {
78            for dep in &skill.dependencies {
79                deps.insert(dep.clone());
80            }
81        }
82        deps.len()
83    }
84}
85
86// ── ConstellationBuilder ───────────────────────────────────────────────────
87
88/// Builds a constellation incrementally.
89#[derive(Debug)]
90pub struct ConstellationBuilder {
91    name: String,
92    version: String,
93    description: String,
94    skills: Vec<SkillDescriptor>,
95}
96
97impl ConstellationBuilder {
98    pub fn new(name: &str) -> Self {
99        Self {
100            name: name.to_string(),
101            version: "0.1.0".to_string(),
102            description: String::new(),
103            skills: Vec::new(),
104        }
105    }
106
107    pub fn version(mut self, v: &str) -> Self {
108        self.version = v.to_string();
109        self
110    }
111
112    pub fn description(mut self, desc: &str) -> Self {
113        self.description = desc.to_string();
114        self
115    }
116
117    pub fn add_skill(mut self, skill: SkillDescriptor) -> Self {
118        self.skills.push(skill);
119        self
120    }
121
122    pub fn build(self) -> Constellation {
123        Constellation {
124            name: self.name,
125            version: self.version,
126            description: self.description,
127            skills: self.skills,
128        }
129    }
130}
131
132// ── ResolutionError ────────────────────────────────────────────────────────
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ResolutionError {
136    pub message: String,
137}
138
139impl ResolutionError {
140    pub fn new(msg: &str) -> Self {
141        Self { message: msg.to_string() }
142    }
143}
144
145impl std::fmt::Display for ResolutionError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        write!(f, "ResolutionError: {}", self.message)
148    }
149}
150
151// ── ConstellationResolver ──────────────────────────────────────────────────
152
153/// Resolves dependencies and detects conflicts within a constellation.
154pub struct ConstellationResolver;
155
156impl ConstellationResolver {
157    /// Collect all transitive dependencies from a constellation's skills.
158    /// Returns a map from dependency name to the set of skills that need it.
159    pub fn collect_dependencies(constellation: &Constellation) -> HashMap<String, HashSet<String>> {
160        let mut dep_map: HashMap<String, HashSet<String>> = HashMap::new();
161        for skill in &constellation.skills {
162            for dep in &skill.dependencies {
163                dep_map.entry(dep.clone())
164                    .or_default()
165                    .insert(skill.name.clone());
166            }
167        }
168        dep_map
169    }
170
171    /// Detect conflicts: skills that depend on each other (circular) or
172    /// the same skill appearing multiple times.
173    pub fn detect_conflicts(constellation: &Constellation) -> Vec<ResolutionError> {
174        let mut errors = Vec::new();
175
176        // Check for duplicate skill names
177        let mut seen = HashSet::new();
178        for skill in &constellation.skills {
179            if !seen.insert(&skill.name) {
180                errors.push(ResolutionError::new(&format!(
181                    "Duplicate skill: {}", skill.name
182                )));
183            }
184        }
185
186        // Check for circular dependencies (skill A depends on skill B which depends on A)
187        let skill_names: HashSet<&str> = constellation.skills.iter().map(|s| s.name.as_str()).collect();
188        for skill in &constellation.skills {
189            for dep in &skill.dependencies {
190                if skill_names.contains(dep.as_str()) {
191                    // dep is another skill in the constellation; check if it depends back
192                    if let Some(dep_skill) = constellation.skills.iter().find(|s| s.name == *dep) {
193                        if dep_skill.dependencies.contains(&skill.name) {
194                            errors.push(ResolutionError::new(&format!(
195                                "Circular dependency: {} <-> {}", skill.name, dep
196                            )));
197                        }
198                    }
199                }
200            }
201        }
202
203        errors
204    }
205
206    /// Get skills that have no unresolved dependencies (ready to load first).
207    pub fn root_skills(constellation: &Constellation) -> Vec<&SkillDescriptor> {
208        let skill_names: HashSet<&str> = constellation.skills.iter().map(|s| s.name.as_str()).collect();
209        constellation.skills.iter()
210            .filter(|s| !s.dependencies.iter().any(|d| skill_names.contains(d.as_str())))
211            .collect()
212    }
213
214    /// Topological sort of skills by dependency order.
215    /// Returns skills in load order (dependencies first), or an error if cycles exist.
216    pub fn dependency_order(constellation: &Constellation) -> Result<Vec<String>, ResolutionError> {
217        let skill_names: HashSet<&str> = constellation.skills.iter().map(|s| s.name.as_str()).collect();
218        let mut in_degree: HashMap<&str, usize> = HashMap::new();
219        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
220
221        for skill in &constellation.skills {
222            in_degree.entry(&skill.name).or_insert(0);
223            adj.entry(&skill.name).or_default();
224        }
225
226        for skill in &constellation.skills {
227            for dep in &skill.dependencies {
228                if skill_names.contains(dep.as_str()) {
229                    *in_degree.entry(&skill.name).or_insert(0) += 1;
230                    adj.entry(dep.as_str()).or_default().push(&skill.name);
231                }
232            }
233        }
234
235        let mut queue: Vec<&str> = in_degree.iter()
236            .filter(|(_, &deg)| deg == 0)
237            .map(|(&name, _)| name)
238            .collect();
239
240        let mut result = Vec::new();
241        while let Some(name) = queue.pop() {
242            result.push(name.to_string());
243            if let Some(neighbors) = adj.get(name) {
244                for &neighbor in neighbors {
245                    if let Some(deg) = in_degree.get_mut(neighbor) {
246                        *deg -= 1;
247                        if *deg == 0 {
248                            queue.push(neighbor);
249                        }
250                    }
251                }
252            }
253        }
254
255        if result.len() != constellation.skills.len() {
256            return Err(ResolutionError::new("Circular dependency detected in topological sort"));
257        }
258
259        Ok(result)
260    }
261}
262
263// ── DeploymentTarget ───────────────────────────────────────────────────────
264
265/// Target platform for constellation compilation.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum DeploymentTarget {
268    /// ESP32 microcontroller.
269    Esp32,
270    /// WebAssembly.
271    Wasm,
272    /// Native binary (Linux/macOS/Windows).
273    Native,
274}
275
276impl std::fmt::Display for DeploymentTarget {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self {
279            DeploymentTarget::Esp32 => write!(f, "esp32"),
280            DeploymentTarget::Wasm => write!(f, "wasm"),
281            DeploymentTarget::Native => write!(f, "native"),
282        }
283    }
284}
285
286// ── CompiledConstellation ──────────────────────────────────────────────────
287
288/// A constellation compiled for a specific target.
289#[derive(Debug, Clone)]
290pub struct CompiledConstellation {
291    pub constellation_name: String,
292    pub target: DeploymentTarget,
293    pub skill_count: usize,
294    pub estimated_size_bytes: usize,
295    pub checksum: String,
296}
297
298// ── ConstellationCompiler ──────────────────────────────────────────────────
299
300/// Compiles a constellation into a bundle for a deployment target.
301pub struct ConstellationCompiler;
302
303impl ConstellationCompiler {
304    /// Compile a constellation for a given target.
305    /// In a real implementation this would invoke cross-compilation toolchains.
306    /// Here we simulate it with size estimates and checksum generation.
307    pub fn compile(
308        constellation: &Constellation,
309        target: DeploymentTarget,
310    ) -> Result<CompiledConstellation, ResolutionError> {
311        let conflicts = ConstellationResolver::detect_conflicts(constellation);
312        if !conflicts.is_empty() {
313            return Err(ResolutionError::new(&format!(
314                "Cannot compile: {} conflicts detected", conflicts.len()
315            )));
316        }
317
318        // Simulated size estimate
319        let base_size = match target {
320            DeploymentTarget::Esp32 => 4096,
321            DeploymentTarget::Wasm => 2048,
322            DeploymentTarget::Native => 8192,
323        };
324        let estimated_size = base_size * constellation.skills.len().max(1);
325
326        // Simple checksum from constellation name + skill names
327        let mut hash_val: u64 = 0;
328        for byte in constellation.name.bytes() {
329            hash_val = hash_val.wrapping_mul(31).wrapping_add(byte as u64);
330        }
331        for skill in &constellation.skills {
332            for byte in skill.name.bytes() {
333                hash_val = hash_val.wrapping_mul(31).wrapping_add(byte as u64);
334            }
335        }
336        let checksum = format!("{:016x}", hash_val);
337
338        Ok(CompiledConstellation {
339            constellation_name: constellation.name.clone(),
340            target,
341            skill_count: constellation.skills.len(),
342            estimated_size_bytes: estimated_size,
343            checksum,
344        })
345    }
346}
347
348// ── ConstellationRegistry ──────────────────────────────────────────────────
349
350/// Fleet-wide catalog of constellations.
351#[derive(Debug)]
352pub struct ConstellationRegistry {
353    constellations: HashMap<String, Constellation>,
354}
355
356impl ConstellationRegistry {
357    pub fn new() -> Self {
358        Self { constellations: HashMap::new() }
359    }
360
361    /// Register a constellation.
362    pub fn register(&mut self, constellation: Constellation) {
363        self.constellations.insert(constellation.name.clone(), constellation);
364    }
365
366    /// Look up a constellation by name.
367    pub fn get(&self, name: &str) -> Option<&Constellation> {
368        self.constellations.get(name)
369    }
370
371    /// Remove a constellation from the registry.
372    pub fn unregister(&mut self, name: &str) -> bool {
373        self.constellations.remove(name).is_some()
374    }
375
376    /// List all registered constellation names.
377    pub fn list_names(&self) -> Vec<&str> {
378        self.constellations.keys().map(|s| s.as_str()).collect()
379    }
380
381    /// Find constellations that contain a given skill.
382    pub fn find_by_skill(&self, skill_name: &str) -> Vec<&Constellation> {
383        self.constellations.values()
384            .filter(|c| c.has_skill(skill_name))
385            .collect()
386    }
387
388    /// Total number of registered constellations.
389    pub fn count(&self) -> usize {
390        self.constellations.len()
391    }
392}
393
394impl Default for ConstellationRegistry {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400// ── Tests ──────────────────────────────────────────────────────────────────
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_skill_descriptor_builder() {
408        let skill = SkillDescriptor::new("navigation", "1.0")
409            .with_dependencies(&["math", "sensor"]);
410        assert_eq!(skill.name, "navigation");
411        assert_eq!(skill.dependencies.len(), 2);
412    }
413
414    #[test]
415    fn test_constellation_new() {
416        let c = Constellation::new("room-alpha");
417        assert_eq!(c.name, "room-alpha");
418        assert!(c.skills.is_empty());
419    }
420
421    #[test]
422    fn test_constellation_has_skill() {
423        let mut c = Constellation::new("room-alpha");
424        c.skills.push(SkillDescriptor::new("nav", "1.0"));
425        assert!(c.has_skill("nav"));
426        assert!(!c.has_skill("comms"));
427    }
428
429    #[test]
430    fn test_constellation_skill_names() {
431        let mut c = Constellation::new("room-alpha");
432        c.skills.push(SkillDescriptor::new("nav", "1.0"));
433        c.skills.push(SkillDescriptor::new("comms", "1.0"));
434        assert_eq!(c.skill_names(), vec!["nav", "comms"]);
435    }
436
437    #[test]
438    fn test_builder_basic() {
439        let c = ConstellationBuilder::new("room-beta")
440            .version("2.0")
441            .description("Test room")
442            .add_skill(SkillDescriptor::new("nav", "1.0"))
443            .add_skill(SkillDescriptor::new("sensor", "1.0").with_dependency("nav"))
444            .build();
445        assert_eq!(c.name, "room-beta");
446        assert_eq!(c.version, "2.0");
447        assert_eq!(c.skills.len(), 2);
448    }
449
450    #[test]
451    fn test_resolver_collect_dependencies() {
452        let c = ConstellationBuilder::new("test")
453            .add_skill(SkillDescriptor::new("a", "1.0").with_dependencies(&["math", "log"]))
454            .add_skill(SkillDescriptor::new("b", "1.0").with_dependencies(&["math"]))
455            .build();
456        let deps = ConstellationResolver::collect_dependencies(&c);
457        assert_eq!(deps.len(), 2); // math, log
458        assert_eq!(deps.get("math").unwrap().len(), 2); // a and b need math
459    }
460
461    #[test]
462    fn test_resolver_no_conflicts() {
463        let c = ConstellationBuilder::new("test")
464            .add_skill(SkillDescriptor::new("a", "1.0"))
465            .add_skill(SkillDescriptor::new("b", "1.0"))
466            .build();
467        let conflicts = ConstellationResolver::detect_conflicts(&c);
468        assert!(conflicts.is_empty());
469    }
470
471    #[test]
472    fn test_resolver_duplicate_conflict() {
473        let c = ConstellationBuilder::new("test")
474            .add_skill(SkillDescriptor::new("dup", "1.0"))
475            .add_skill(SkillDescriptor::new("dup", "1.0"))
476            .build();
477        let conflicts = ConstellationResolver::detect_conflicts(&c);
478        assert_eq!(conflicts.len(), 1);
479        assert!(conflicts[0].message.contains("Duplicate"));
480    }
481
482    #[test]
483    fn test_resolver_circular_conflict() {
484        let c = ConstellationBuilder::new("test")
485            .add_skill(SkillDescriptor::new("a", "1.0").with_dependency("b"))
486            .add_skill(SkillDescriptor::new("b", "1.0").with_dependency("a"))
487            .build();
488        let conflicts = ConstellationResolver::detect_conflicts(&c);
489        assert!(conflicts.iter().any(|e| e.message.contains("Circular")));
490    }
491
492    #[test]
493    fn test_resolver_root_skills() {
494        let c = ConstellationBuilder::new("test")
495            .add_skill(SkillDescriptor::new("base", "1.0"))
496            .add_skill(SkillDescriptor::new("derived", "1.0").with_dependency("base"))
497            .build();
498        let roots = ConstellationResolver::root_skills(&c);
499        assert_eq!(roots.len(), 1);
500        assert_eq!(roots[0].name, "base");
501    }
502
503    #[test]
504    fn test_resolver_dependency_order() {
505        let c = ConstellationBuilder::new("test")
506            .add_skill(SkillDescriptor::new("c", "1.0").with_dependency("b"))
507            .add_skill(SkillDescriptor::new("b", "1.0").with_dependency("a"))
508            .add_skill(SkillDescriptor::new("a", "1.0"))
509            .build();
510        let order = ConstellationResolver::dependency_order(&c).unwrap();
511        let a_pos = order.iter().position(|n| n == "a").unwrap();
512        let b_pos = order.iter().position(|n| n == "b").unwrap();
513        let c_pos = order.iter().position(|n| n == "c").unwrap();
514        assert!(a_pos < b_pos);
515        assert!(b_pos < c_pos);
516    }
517
518    #[test]
519    fn test_resolver_dependency_order_cycle_fails() {
520        let c = ConstellationBuilder::new("test")
521            .add_skill(SkillDescriptor::new("a", "1.0").with_dependency("b"))
522            .add_skill(SkillDescriptor::new("b", "1.0").with_dependency("a"))
523            .build();
524        assert!(ConstellationResolver::dependency_order(&c).is_err());
525    }
526
527    #[test]
528    fn test_compiler_esp32() {
529        let c = ConstellationBuilder::new("room-x")
530            .add_skill(SkillDescriptor::new("nav", "1.0"))
531            .add_skill(SkillDescriptor::new("comms", "1.0"))
532            .build();
533        let compiled = ConstellationCompiler::compile(&c, DeploymentTarget::Esp32).unwrap();
534        assert_eq!(compiled.target, DeploymentTarget::Esp32);
535        assert_eq!(compiled.skill_count, 2);
536        assert!(compiled.estimated_size_bytes > 0);
537    }
538
539    #[test]
540    fn test_compiler_wasm() {
541        let c = ConstellationBuilder::new("room-y")
542            .add_skill(SkillDescriptor::new("skill", "1.0"))
543            .build();
544        let compiled = ConstellationCompiler::compile(&c, DeploymentTarget::Wasm).unwrap();
545        assert_eq!(compiled.target, DeploymentTarget::Wasm);
546        assert!(compiled.estimated_size_bytes < 100000); // reasonable
547    }
548
549    #[test]
550    fn test_compiler_conflict_fails() {
551        let c = ConstellationBuilder::new("bad")
552            .add_skill(SkillDescriptor::new("dup", "1.0"))
553            .add_skill(SkillDescriptor::new("dup", "1.0"))
554            .build();
555        assert!(ConstellationCompiler::compile(&c, DeploymentTarget::Native).is_err());
556    }
557
558    #[test]
559    fn test_registry_register_and_get() {
560        let mut reg = ConstellationRegistry::new();
561        let c = ConstellationBuilder::new("room-a")
562            .add_skill(SkillDescriptor::new("nav", "1.0"))
563            .build();
564        reg.register(c);
565        assert!(reg.get("room-a").is_some());
566        assert!(reg.get("room-b").is_none());
567    }
568
569    #[test]
570    fn test_registry_find_by_skill() {
571        let mut reg = ConstellationRegistry::new();
572        reg.register(ConstellationBuilder::new("room-a")
573            .add_skill(SkillDescriptor::new("nav", "1.0"))
574            .build());
575        reg.register(ConstellationBuilder::new("room-b")
576            .add_skill(SkillDescriptor::new("comms", "1.0"))
577            .build());
578        let found = reg.find_by_skill("nav");
579        assert_eq!(found.len(), 1);
580        assert_eq!(found[0].name, "room-a");
581    }
582
583    #[test]
584    fn test_registry_unregister() {
585        let mut reg = ConstellationRegistry::new();
586        reg.register(Constellation::new("room-x"));
587        assert!(reg.unregister("room-x"));
588        assert!(!reg.unregister("room-x"));
589        assert_eq!(reg.count(), 0);
590    }
591
592    #[test]
593    fn test_compiled_checksum_deterministic() {
594        let c = ConstellationBuilder::new("test")
595            .add_skill(SkillDescriptor::new("x", "1.0"))
596            .build();
597        let c1 = ConstellationCompiler::compile(&c, DeploymentTarget::Native).unwrap();
598        let c2 = ConstellationCompiler::compile(&c, DeploymentTarget::Native).unwrap();
599        assert_eq!(c1.checksum, c2.checksum);
600    }
601}