Skip to main content

formal_ai/
associative_package.rs

1//! Reusable associative packages, handlers, triggers, and permissions.
2//!
3//! Packages are the local, doublet-backed adaptation of the Deep.Foundation
4//! package idea from R65: a package is reviewable Links Notation that can be
5//! installed, dependency-checked, exported/imported, replayed, and asked
6//! whether it grants a tool or action capability.
7
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt;
11
12use lino_objects_codec::format::parse_indented;
13
14use crate::engine::{normalize_prompt, stable_id, GraphEdge, GraphNode, KNOWLEDGE_SCHEMA_VERSION};
15use crate::link_store::{DoubletLink, LinkRecord};
16use crate::links_format::push_lino_node;
17use crate::seed::parser::{parse_lino, LinoNode};
18use crate::skill_compiler::CompiledSkillPackage;
19
20/// A package dependency by package id and optional exact version.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct PackageDependency {
23    pub package_id: String,
24    pub version: String,
25}
26
27/// A package-provided executable handler.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PackageHandler {
30    pub id: String,
31    pub kind: String,
32    pub capability: String,
33    pub response: String,
34}
35
36/// Trigger-style computation over package links.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PackageTrigger {
39    pub id: String,
40    pub kind: String,
41    pub match_prompt: String,
42    pub normalized_match: String,
43    pub handler_id: String,
44}
45
46/// Explicit package permission grant.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PackagePermission {
49    pub id: String,
50    pub capability: String,
51    pub effect: String,
52    pub description: String,
53}
54
55/// A reusable associative package.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct AssociativePackage {
58    pub id: String,
59    pub name: String,
60    pub version: String,
61    pub dependencies: Vec<PackageDependency>,
62    pub handlers: Vec<PackageHandler>,
63    pub triggers: Vec<PackageTrigger>,
64    pub permissions: Vec<PackagePermission>,
65}
66
67/// Deterministic replay result from an installed package.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct PackageReplay {
70    pub package_id: String,
71    pub trigger_id: String,
72    pub handler_id: String,
73    pub answer: String,
74    pub cache_hit: String,
75}
76
77/// Permission decision for package-gated tools/actions.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum PackagePermissionDecision {
80    Allowed {
81        package_id: String,
82        permission_id: String,
83        capability: String,
84    },
85    Denied {
86        capability: String,
87        reason: String,
88    },
89}
90
91/// Installed package registry with deterministic dependency validation.
92#[derive(Debug, Default, Clone, PartialEq, Eq)]
93pub struct PackageStore {
94    packages: BTreeMap<String, AssociativePackage>,
95}
96
97/// Package installation failure.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum PackageInstallError {
100    MissingDependency {
101        package_id: String,
102        dependency_id: String,
103    },
104    VersionMismatch {
105        package_id: String,
106        dependency_id: String,
107        required: String,
108        installed: String,
109    },
110}
111
112/// Package import failure.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum PackageImportError {
115    EmptyDocument,
116    IllFormedLinksNotation(String),
117    NotAssociativePackage(String),
118    MissingField(&'static str),
119}
120
121impl fmt::Display for PackageInstallError {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::MissingDependency {
125                package_id,
126                dependency_id,
127            } => write!(
128                formatter,
129                "package {package_id} requires missing dependency {dependency_id}"
130            ),
131            Self::VersionMismatch {
132                package_id,
133                dependency_id,
134                required,
135                installed,
136            } => write!(
137                formatter,
138                "package {package_id} requires {dependency_id}@{required}, installed {installed}"
139            ),
140        }
141    }
142}
143
144impl Error for PackageInstallError {}
145
146impl fmt::Display for PackageImportError {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            Self::EmptyDocument => write!(formatter, "package document is empty"),
150            Self::IllFormedLinksNotation(message) => {
151                write!(formatter, "ill-formed package Links Notation: {message}")
152            }
153            Self::NotAssociativePackage(id) => {
154                write!(formatter, "{id} is not an associative package")
155            }
156            Self::MissingField(field) => write!(formatter, "package is missing {field}"),
157        }
158    }
159}
160
161impl Error for PackageImportError {}
162
163impl PackageHandler {
164    #[must_use]
165    pub fn new(id: &str, kind: &str, capability: &str) -> Self {
166        Self {
167            id: id.to_owned(),
168            kind: kind.to_owned(),
169            capability: capability.to_owned(),
170            response: String::new(),
171        }
172    }
173
174    #[must_use]
175    pub fn with_response(mut self, response: &str) -> Self {
176        response.clone_into(&mut self.response);
177        self
178    }
179}
180
181impl PackageTrigger {
182    #[must_use]
183    pub fn new(id: &str, kind: &str, match_prompt: &str, handler_id: &str) -> Self {
184        Self {
185            id: id.to_owned(),
186            kind: kind.to_owned(),
187            match_prompt: match_prompt.to_owned(),
188            normalized_match: normalize_prompt(match_prompt),
189            handler_id: handler_id.to_owned(),
190        }
191    }
192}
193
194impl AssociativePackage {
195    #[must_use]
196    pub fn new(id: &str, name: &str, version: &str) -> Self {
197        Self {
198            id: id.to_owned(),
199            name: name.to_owned(),
200            version: version.to_owned(),
201            dependencies: Vec::new(),
202            handlers: Vec::new(),
203            triggers: Vec::new(),
204            permissions: Vec::new(),
205        }
206    }
207
208    #[must_use]
209    pub fn from_compiled_skill(
210        id: &str,
211        name: &str,
212        version: &str,
213        skill: &CompiledSkillPackage,
214    ) -> Self {
215        let mut package = Self::new(id, name, version)
216            .with_handler(
217                PackageHandler::new(
218                    &skill.handler_id,
219                    "deterministic_response",
220                    &format!("handler:{}", skill.handler_id),
221                )
222                .with_response(&skill.response),
223            )
224            .with_trigger(PackageTrigger {
225                id: skill.rule_id.clone(),
226                kind: String::from("exact_normalized_prompt"),
227                match_prompt: skill.trigger.clone(),
228                normalized_match: skill.normalized_trigger.clone(),
229                handler_id: skill.handler_id.clone(),
230            });
231        for test in &skill.expected_tests {
232            if test.normalized_input == skill.normalized_trigger
233                && test.expected_output == skill.response
234            {
235                continue;
236            }
237            package = package
238                .with_handler(
239                    PackageHandler::new(
240                        &test.handler_id,
241                        "deterministic_response",
242                        &format!("handler:{}", test.handler_id),
243                    )
244                    .with_response(&test.expected_output),
245                )
246                .with_trigger(PackageTrigger {
247                    id: test.trigger_id.clone(),
248                    kind: String::from("exact_normalized_prompt"),
249                    match_prompt: test.input.clone(),
250                    normalized_match: test.normalized_input.clone(),
251                    handler_id: test.handler_id.clone(),
252                });
253        }
254        for permission in &skill.required_permissions {
255            package = package.with_permission(&permission.capability, &permission.description);
256        }
257        package
258    }
259
260    #[must_use]
261    pub fn with_dependency(mut self, package_id: &str, version: &str) -> Self {
262        self.dependencies.push(PackageDependency {
263            package_id: package_id.to_owned(),
264            version: version.to_owned(),
265        });
266        self
267    }
268
269    #[must_use]
270    pub fn with_handler(mut self, handler: PackageHandler) -> Self {
271        self.handlers.push(handler);
272        self
273    }
274
275    #[must_use]
276    pub fn with_trigger(mut self, trigger: PackageTrigger) -> Self {
277        self.triggers.push(trigger);
278        self
279    }
280
281    #[must_use]
282    pub fn with_permission(mut self, capability: &str, description: &str) -> Self {
283        let id = stable_id(
284            "package_permission",
285            &format!("{}:{capability}:{description}", self.id),
286        );
287        self.permissions.push(PackagePermission {
288            id,
289            capability: capability.to_owned(),
290            effect: String::from("allow"),
291            description: description.to_owned(),
292        });
293        self
294    }
295
296    #[must_use]
297    pub fn replay(&self, prompt: &str) -> Option<PackageReplay> {
298        let normalized = normalize_prompt(prompt);
299        let trigger = self.triggers.iter().find(|trigger| {
300            trigger.kind == "exact_normalized_prompt" && trigger.normalized_match == normalized
301        })?;
302        let handler = self
303            .handlers
304            .iter()
305            .find(|handler| handler.id == trigger.handler_id)?;
306        if handler.kind != "deterministic_response" || handler.response.is_empty() {
307            return None;
308        }
309        Some(PackageReplay {
310            package_id: self.id.clone(),
311            trigger_id: trigger.id.clone(),
312            handler_id: handler.id.clone(),
313            answer: handler.response.clone(),
314            cache_hit: self.id.clone(),
315        })
316    }
317
318    #[must_use]
319    pub fn links_notation(&self) -> String {
320        let mut out = String::new();
321        push_lino_node(&mut out, 0, &self.id, None);
322        push_lino_node(&mut out, 2, "type", Some("associative_package"));
323        push_lino_node(
324            &mut out,
325            2,
326            "schema_version",
327            Some(KNOWLEDGE_SCHEMA_VERSION),
328        );
329        push_lino_node(&mut out, 2, "name", Some(&self.name));
330        push_lino_node(&mut out, 2, "version", Some(&self.version));
331        push_lino_node(
332            &mut out,
333            2,
334            "source",
335            Some("R65 Deep.Foundation-inspired local package model"),
336        );
337        for dependency in &self.dependencies {
338            push_lino_node(&mut out, 2, "dependency", Some(&dependency.package_id));
339            push_lino_node(&mut out, 4, "version", Some(&dependency.version));
340        }
341        for handler in &self.handlers {
342            push_lino_node(&mut out, 2, "handler", Some(&handler.id));
343            push_lino_node(&mut out, 4, "kind", Some(&handler.kind));
344            push_lino_node(&mut out, 4, "capability", Some(&handler.capability));
345            push_lino_node(&mut out, 4, "response", Some(&handler.response));
346        }
347        for trigger in &self.triggers {
348            push_lino_node(&mut out, 2, "trigger", Some(&trigger.id));
349            push_lino_node(&mut out, 4, "kind", Some(&trigger.kind));
350            push_lino_node(&mut out, 4, "match_prompt", Some(&trigger.match_prompt));
351            push_lino_node(
352                &mut out,
353                4,
354                "normalized_match",
355                Some(&trigger.normalized_match),
356            );
357            push_lino_node(&mut out, 4, "handler", Some(&trigger.handler_id));
358        }
359        for permission in &self.permissions {
360            push_lino_node(&mut out, 2, "permission", Some(&permission.id));
361            push_lino_node(&mut out, 4, "effect", Some(&permission.effect));
362            push_lino_node(&mut out, 4, "capability", Some(&permission.capability));
363            push_lino_node(&mut out, 4, "description", Some(&permission.description));
364        }
365        out.trim_end().to_owned()
366    }
367
368    pub fn from_links_notation(text: &str) -> Result<Self, PackageImportError> {
369        let trimmed = text.trim();
370        if trimmed.is_empty() {
371            return Err(PackageImportError::EmptyDocument);
372        }
373        parse_indented(trimmed)
374            .map_err(|error| PackageImportError::IllFormedLinksNotation(format!("{error:?}")))?;
375        let tree = parse_lino(trimmed);
376        let root = tree
377            .children
378            .first()
379            .ok_or(PackageImportError::EmptyDocument)?;
380        if root.find_child_value("type") != "associative_package" {
381            return Err(PackageImportError::NotAssociativePackage(root.name.clone()));
382        }
383        let name = required_child(root, "name")?;
384        let version = required_child(root, "version")?;
385        let mut package = Self::new(&root.name, name, version);
386        for child in &root.children {
387            match child.name.as_str() {
388                "dependency" => {
389                    package.dependencies.push(PackageDependency {
390                        package_id: child.id.clone(),
391                        version: child.find_child_value("version").to_owned(),
392                    });
393                }
394                "handler" => {
395                    package.handlers.push(PackageHandler {
396                        id: child.id.clone(),
397                        kind: child.find_child_value("kind").to_owned(),
398                        capability: child.find_child_value("capability").to_owned(),
399                        response: child.find_child_value("response").to_owned(),
400                    });
401                }
402                "trigger" => {
403                    package.triggers.push(PackageTrigger {
404                        id: child.id.clone(),
405                        kind: child.find_child_value("kind").to_owned(),
406                        match_prompt: child.find_child_value("match_prompt").to_owned(),
407                        normalized_match: child.find_child_value("normalized_match").to_owned(),
408                        handler_id: child.find_child_value("handler").to_owned(),
409                    });
410                }
411                "permission" => {
412                    package.permissions.push(PackagePermission {
413                        id: child.id.clone(),
414                        effect: child.find_child_value("effect").to_owned(),
415                        capability: child.find_child_value("capability").to_owned(),
416                        description: child.find_child_value("description").to_owned(),
417                    });
418                }
419                _ => {}
420            }
421        }
422        Ok(package)
423    }
424
425    #[must_use]
426    pub fn grants_capability(&self, capability: &str) -> Option<&PackagePermission> {
427        self.permissions
428            .iter()
429            .find(|permission| permission.effect == "allow" && permission.capability == capability)
430    }
431
432    #[must_use]
433    pub fn link_records(&self) -> Vec<LinkRecord> {
434        let mut records = vec![link_record(
435            &self.id,
436            "AssociativePackage",
437            "associative_package",
438            "R65",
439            &[
440                ("name", self.name.as_str()),
441                ("version", self.version.as_str()),
442                ("source", "Deep.Foundation-inspired local package model"),
443            ],
444        )];
445        for dependency in &self.dependencies {
446            records.push(link_record(
447                &stable_id(
448                    "package_dependency",
449                    &format!("{}:{}", self.id, dependency.package_id),
450                ),
451                "PackageDependency",
452                "dependency_link",
453                &self.id,
454                &[
455                    ("package_id", dependency.package_id.as_str()),
456                    ("version", dependency.version.as_str()),
457                ],
458            ));
459        }
460        for handler in &self.handlers {
461            records.push(link_record(
462                &handler.id,
463                "PackageHandler",
464                "compiled_handler",
465                &self.id,
466                &[
467                    ("kind", handler.kind.as_str()),
468                    ("capability", handler.capability.as_str()),
469                ],
470            ));
471        }
472        for trigger in &self.triggers {
473            records.push(link_record(
474                &trigger.id,
475                "PackageTrigger",
476                "trigger_rule",
477                &self.id,
478                &[
479                    ("kind", trigger.kind.as_str()),
480                    ("match_prompt", trigger.match_prompt.as_str()),
481                    ("handler", trigger.handler_id.as_str()),
482                ],
483            ));
484        }
485        for permission in &self.permissions {
486            records.push(link_record(
487                &permission.id,
488                "PackagePermission",
489                "permission_grant",
490                &self.id,
491                &[
492                    ("effect", permission.effect.as_str()),
493                    ("capability", permission.capability.as_str()),
494                    ("description", permission.description.as_str()),
495                ],
496            ));
497        }
498        records
499    }
500}
501
502impl PackageStore {
503    #[must_use]
504    pub const fn new() -> Self {
505        Self {
506            packages: BTreeMap::new(),
507        }
508    }
509
510    pub fn install(&mut self, package: AssociativePackage) -> Result<(), PackageInstallError> {
511        for dependency in &package.dependencies {
512            let Some(installed) = self.packages.get(&dependency.package_id) else {
513                return Err(PackageInstallError::MissingDependency {
514                    package_id: package.id.clone(),
515                    dependency_id: dependency.package_id.clone(),
516                });
517            };
518            if !dependency.version.is_empty() && installed.version != dependency.version {
519                return Err(PackageInstallError::VersionMismatch {
520                    package_id: package.id.clone(),
521                    dependency_id: dependency.package_id.clone(),
522                    required: dependency.version.clone(),
523                    installed: installed.version.clone(),
524                });
525            }
526        }
527        self.packages.insert(package.id.clone(), package);
528        Ok(())
529    }
530
531    #[must_use]
532    pub fn replay(&self, prompt: &str) -> Option<PackageReplay> {
533        self.packages
534            .values()
535            .find_map(|package| package.replay(prompt))
536    }
537
538    #[must_use]
539    pub fn permission_for_capability(&self, capability: &str) -> PackagePermissionDecision {
540        for package in self.packages.values() {
541            if let Some(permission) = package.grants_capability(capability) {
542                return PackagePermissionDecision::Allowed {
543                    package_id: package.id.clone(),
544                    permission_id: permission.id.clone(),
545                    capability: capability.to_owned(),
546                };
547            }
548        }
549        PackagePermissionDecision::Denied {
550            capability: capability.to_owned(),
551            reason: format!("no installed associative package grants {capability}"),
552        }
553    }
554
555    #[must_use]
556    pub fn permission_for_tool(&self, tool_name: &str) -> PackagePermissionDecision {
557        self.permission_for_capability(&format!("tool:{tool_name}"))
558    }
559
560    #[must_use]
561    pub fn packages(&self) -> Vec<&AssociativePackage> {
562        self.packages.values().collect()
563    }
564}
565
566#[must_use]
567pub fn default_associative_packages() -> Vec<AssociativePackage> {
568    vec![
569        AssociativePackage::new(
570            "pkg_formal_ai_core",
571            "formal-ai core package",
572            KNOWLEDGE_SCHEMA_VERSION,
573        )
574        .with_handler(PackageHandler::new(
575            "handler_calculator",
576            "rust_handler",
577            "tool:calculator",
578        ))
579        .with_trigger(PackageTrigger::new(
580            "trigger_calculator_tool_call",
581            "tool_invocation",
582            "calculator",
583            "handler_calculator",
584        ))
585        .with_handler(PackageHandler::new(
586            "handler_web_search",
587            "rust_handler",
588            "tool:web_search",
589        ))
590        .with_trigger(PackageTrigger::new(
591            "trigger_web_search_tool_call",
592            "tool_invocation",
593            "web_search",
594            "handler_web_search",
595        ))
596        .with_handler(PackageHandler::new(
597            "handler_javascript_execution",
598            "rust_handler",
599            "tool:javascript_execution",
600        ))
601        .with_trigger(PackageTrigger::new(
602            "trigger_javascript_execution_tool_call",
603            "tool_invocation",
604            "javascript_execution",
605            "handler_javascript_execution",
606        ))
607        .with_permission("tool:calculator", "local deterministic calculator tool")
608        .with_permission("tool:web_search", "browser-backed web search API")
609        .with_permission(
610            "tool:javascript_execution",
611            "bounded deterministic JavaScript expression execution",
612        )
613        .with_permission("tool:concept_lookup", "seed-backed concept lookup")
614        .with_permission(
615            "tool:write_program",
616            "seed-backed program template renderer",
617        ),
618        // Agentic-coding capability grants for issue #468. These tools are run by
619        // the *client* (an external agentic CLI, or the in-repo offline driver), not
620        // by an in-server handler, so they are permission-only. The chat tool gate
621        // still refuses every tool unless `agent_mode` is explicitly opted in, so
622        // granting them by default enables no hidden autonomous action — it only lets
623        // an agent that already opted in drive the full search → fetch → write → run
624        // loop the issue asks for. `tool:web_search` is granted by the core package.
625        AssociativePackage::new(
626            "pkg_agentic_coding",
627            "agentic-coding capability package",
628            KNOWLEDGE_SCHEMA_VERSION,
629        )
630        .with_permission(
631            "tool:web_fetch",
632            "agentic-coding HTTP source fetch (client-executed)",
633        )
634        .with_permission(
635            "tool:write_file",
636            "agentic-coding workspace file write (client-executed)",
637        )
638        .with_permission(
639            "tool:run_command",
640            "agentic-coding sandboxed command runner (client-executed)",
641        )
642        // Capability-*class* grants: an agentic client executes tools in its own
643        // isolated sandbox, so what the server authorises is a *kind* of action,
644        // not a specific tool name. Any CLI's naming for a class — `web_search`
645        // / `websearch`, `read` / `read_file`, `write` / `write_file`, `bash` /
646        // `run_command` — maps to the same grant (see
647        // `planner::Capability::permission_key`), so the gate admits every real
648        // agentic CLI's toolset without a per-name allowlist. The `agent_mode`
649        // opt-in remains the real guard on whether tools run at all; these grants
650        // only say *which kinds* an opted-in client may drive.
651        .with_permission(
652            "tool:capability:search",
653            "agentic-coding web search (any CLI naming, client-executed)",
654        )
655        .with_permission(
656            "tool:capability:fetch",
657            "agentic-coding source fetch (any CLI naming, client-executed)",
658        )
659        .with_permission(
660            "tool:capability:read",
661            "agentic-coding file read (any CLI naming, client-executed)",
662        )
663        .with_permission(
664            "tool:capability:write",
665            "agentic-coding file write (any CLI naming, client-executed)",
666        )
667        .with_permission(
668            "tool:capability:edit",
669            "agentic-coding file edit (any CLI naming, client-executed)",
670        )
671        .with_permission(
672            "tool:capability:run",
673            "agentic-coding command runner (any CLI naming, client-executed)",
674        )
675        .with_permission(
676            "tool:capability:grep",
677            "agentic-coding code search (any CLI naming, client-executed)",
678        )
679        .with_permission(
680            "tool:capability:glob",
681            "agentic-coding file glob (any CLI naming, client-executed)",
682        )
683        .with_permission(
684            "tool:capability:list_dir",
685            "agentic-coding directory listing (any CLI naming, client-executed)",
686        )
687        .with_permission(
688            "tool:capability:todo",
689            "agentic-coding planning scratchpad (any CLI naming, client-executed)",
690        )
691        .with_permission(
692            "tool:capability:subagent",
693            "agentic-coding delegation (any CLI naming, client-executed)",
694        )
695        .with_permission(
696            "tool:capability:read_many",
697            "agentic-coding multi-file read (any CLI naming, client-executed)",
698        )
699        .with_permission(
700            "tool:capability:multi_edit",
701            "agentic-coding multi-file edit (any CLI naming, client-executed)",
702        ),
703    ]
704}
705
706#[must_use]
707pub fn default_package_store() -> PackageStore {
708    let mut store = PackageStore::new();
709    for package in default_associative_packages() {
710        store
711            .install(package)
712            .expect("default packages have no invalid dependencies");
713    }
714    store
715}
716
717#[must_use]
718pub(crate) fn default_package_graph_projection() -> (Vec<GraphNode>, Vec<GraphEdge>) {
719    let mut nodes = Vec::new();
720    let mut edges = Vec::new();
721    for package in default_associative_packages() {
722        nodes.push(GraphNode {
723            id: package.id.clone(),
724            label: package.name.clone(),
725            links_notation: package.links_notation(),
726        });
727        edges.push(GraphEdge {
728            from: String::from("formal_ai_knowledge"),
729            to: package.id.clone(),
730            role: String::from("package"),
731        });
732        for handler in &package.handlers {
733            nodes.push(GraphNode {
734                id: handler.id.clone(),
735                label: format!("Package handler: {}", handler.capability),
736                links_notation: format!("{} kind={}", handler.id, handler.kind),
737            });
738            edges.push(GraphEdge {
739                from: package.id.clone(),
740                to: handler.id.clone(),
741                role: String::from("package_handler"),
742            });
743        }
744        for trigger in &package.triggers {
745            nodes.push(GraphNode {
746                id: trigger.id.clone(),
747                label: format!("Package trigger: {}", trigger.kind),
748                links_notation: format!("{} handler={}", trigger.id, trigger.handler_id),
749            });
750            edges.push(GraphEdge {
751                from: package.id.clone(),
752                to: trigger.id.clone(),
753                role: String::from("package_trigger"),
754            });
755            edges.push(GraphEdge {
756                from: trigger.id.clone(),
757                to: trigger.handler_id.clone(),
758                role: String::from("trigger_handler"),
759            });
760        }
761        for permission in &package.permissions {
762            nodes.push(GraphNode {
763                id: permission.id.clone(),
764                label: format!("Package permission: {}", permission.capability),
765                links_notation: format!("{} effect={}", permission.id, permission.effect),
766            });
767            edges.push(GraphEdge {
768                from: package.id.clone(),
769                to: permission.id.clone(),
770                role: String::from("package_permission"),
771            });
772        }
773    }
774    (nodes, edges)
775}
776
777fn required_child<'a>(
778    node: &'a LinoNode,
779    name: &'static str,
780) -> Result<&'a str, PackageImportError> {
781    let value = node.find_child_value(name);
782    if value.is_empty() {
783        Err(PackageImportError::MissingField(name))
784    } else {
785        Ok(value)
786    }
787}
788
789fn link_record(
790    record_id: &str,
791    record_type: &str,
792    subtype: &str,
793    source_id: &str,
794    fields: &[(&str, &str)],
795) -> LinkRecord {
796    let mut links = Vec::new();
797    push_doublet(&mut links, record_id, "Type");
798    push_doublet(&mut links, "Type", record_type);
799    push_doublet(&mut links, record_type, "SubType");
800    push_doublet(&mut links, "SubType", subtype);
801    push_doublet(&mut links, subtype, "Value");
802    push_doublet(&mut links, record_id, source_id);
803    push_field(
804        &mut links,
805        record_id,
806        "schema_version",
807        KNOWLEDGE_SCHEMA_VERSION,
808    );
809    for (key, value) in fields {
810        push_field(&mut links, record_id, key, value);
811    }
812    LinkRecord {
813        stable_id: record_id.to_owned(),
814        schema_version: KNOWLEDGE_SCHEMA_VERSION.to_owned(),
815        record_type: record_type.to_owned(),
816        source_id: source_id.to_owned(),
817        links,
818    }
819}
820
821fn push_field(links: &mut Vec<DoubletLink>, record_id: &str, key: &str, value: &str) {
822    if value.is_empty() {
823        return;
824    }
825    let field = format!("field:{key}");
826    let field_value = format!("value:{value}");
827    push_doublet(links, record_id, &field);
828    push_doublet(links, &field, &field_value);
829}
830
831fn push_doublet(links: &mut Vec<DoubletLink>, from: &str, to: &str) {
832    links.push(DoubletLink {
833        index: stable_id("doublet", &format!("{from}->{to}")),
834        from: from.to_owned(),
835        to: to.to_owned(),
836    });
837}