Skip to main content

sim_lib_topology/
package.rs

1//! Topology package parsing and loading.
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use sim_kernel::{CapabilityName, Cx, Error, Expr, Result, Symbol, Value};
9
10use crate::{
11    Graph, GraphTest,
12    capability::{capability_name_from_text, capability_symbol},
13    text::graph_from_text,
14};
15
16/// Reloadable source descriptor for topology package text.
17#[derive(Clone, Debug)]
18pub enum TopologyPackageSource {
19    /// Compatibility host-file source for `topology/load-file`.
20    HostFile {
21        /// Path read by the compatibility host-file loader.
22        path: PathBuf,
23    },
24    /// Source text read from a runtime table entry.
25    TableEntry {
26        /// Table or Dir value that supplies package text.
27        table: Value,
28        /// Key looked up inside the table.
29        key: Symbol,
30    },
31}
32
33impl TopologyPackageSource {
34    /// Creates a host-file package source descriptor.
35    pub fn host_file(path: impl Into<PathBuf>) -> Self {
36        Self::HostFile { path: path.into() }
37    }
38
39    /// Creates a table-backed package source descriptor.
40    pub fn table_entry(table: Value, key: Symbol) -> Self {
41        Self::TableEntry { table, key }
42    }
43
44    pub(crate) fn read_to_string(&self, cx: &mut Cx) -> Result<String> {
45        match self {
46            Self::HostFile { path } => read_host_file(path),
47            Self::TableEntry { table, key } => read_table_entry(cx, table, key),
48        }
49    }
50
51    pub(crate) fn requires_topology_file(&self) -> bool {
52        matches!(self, Self::HostFile { .. })
53    }
54}
55
56/// Parsed `.simtopo` package.
57#[derive(Clone, Debug)]
58pub struct TopologyPackage {
59    /// Package graph.
60    pub graph: Graph,
61    /// Package test declarations mirrored from the graph.
62    pub tests: Vec<GraphTest>,
63    /// Package metadata mirrored from the graph.
64    pub metadata: Vec<(Symbol, Expr)>,
65    /// Capabilities required by the graph.
66    pub capabilities: Vec<CapabilityName>,
67}
68
69impl TopologyPackage {
70    /// Returns the graph name used as the package artifact name.
71    pub fn name(&self) -> &Symbol {
72        &self.graph.name
73    }
74}
75
76/// Parses a `.simtopo` package.
77///
78/// The package format is intentionally section based. `graph:` contains the
79/// topology text DSL, `tests:` contains `test` lines or shorthand test bodies,
80/// `metadata:` contains `meta` lines or `key=value` shorthand, and
81/// `capabilities:` contains one capability name per non-empty line.
82pub fn parse_package(source: &str) -> Result<TopologyPackage> {
83    let sections = PackageSections::parse(source)?;
84    let mut graph_text = String::new();
85
86    append_lines(&mut graph_text, &sections.graph);
87    for line in &sections.metadata {
88        let trimmed = line.trim();
89        if trimmed.starts_with("meta ") {
90            graph_text.push_str(trimmed);
91        } else {
92            graph_text.push_str("meta ");
93            graph_text.push_str(trimmed);
94        }
95        graph_text.push('\n');
96    }
97    for line in &sections.tests {
98        let trimmed = line.trim();
99        if trimmed.starts_with("test ") {
100            graph_text.push_str(trimmed);
101        } else {
102            graph_text.push_str("test ");
103            graph_text.push_str(trimmed);
104        }
105        graph_text.push('\n');
106    }
107
108    let capabilities = sections
109        .capabilities
110        .iter()
111        .map(|line| parse_capability_line(line.line_no, &line.text))
112        .collect::<Result<Vec<_>>>()?;
113
114    let mut graph = graph_from_text(&graph_text)?;
115    graph.capabilities = capabilities.iter().map(capability_symbol).collect();
116
117    Ok(TopologyPackage {
118        tests: graph.tests.clone(),
119        metadata: graph.metadata.clone(),
120        capabilities,
121        graph,
122    })
123}
124
125/// Reads and parses a `.simtopo` package from disk.
126pub fn load_package_file(path: impl Into<PathBuf>) -> Result<TopologyPackage> {
127    let path = path.into();
128    let source = read_host_file(&path)?;
129    parse_package(&source)
130}
131
132/// Reads and parses a topology package from a reloadable source descriptor.
133pub fn load_package_source(cx: &mut Cx, source: &TopologyPackageSource) -> Result<TopologyPackage> {
134    parse_package(&source.read_to_string(cx)?)
135}
136
137fn read_host_file(path: &Path) -> Result<String> {
138    let bytes = fs::read(path).map_err(|err| {
139        Error::HostError(format!(
140            "failed to read topology package {}: {err}",
141            path.display()
142        ))
143    })?;
144    String::from_utf8(bytes).map_err(|err| {
145        Error::HostError(format!(
146            "failed to decode topology package {} as utf-8: {err}",
147            path.display()
148        ))
149    })
150}
151
152fn read_table_entry(cx: &mut Cx, table: &Value, key: &Symbol) -> Result<String> {
153    let table = table.object().as_table_impl().ok_or(Error::TypeMismatch {
154        expected: "table",
155        found: "non-table",
156    })?;
157    let value = table.get(cx, key.clone())?;
158    match value.object().as_expr(cx)? {
159        Expr::String(source) => Ok(source),
160        Expr::Bytes(bytes) => String::from_utf8(bytes).map_err(|err| {
161            Error::Eval(format!(
162                "topology package table entry {key} is not utf-8: {err}"
163            ))
164        }),
165        Expr::Nil => Err(Error::Eval(format!(
166            "topology package table entry {key} is missing"
167        ))),
168        other => Err(Error::TypeMismatch {
169            expected: "string or bytes package source",
170            found: expr_type(&other),
171        }),
172    }
173}
174
175#[derive(Clone, Copy)]
176enum Section {
177    Graph,
178    Tests,
179    Metadata,
180    Capabilities,
181}
182
183#[derive(Default)]
184struct PackageSections {
185    graph: Vec<String>,
186    tests: Vec<String>,
187    metadata: Vec<String>,
188    capabilities: Vec<PackageLine>,
189}
190
191impl PackageSections {
192    fn parse(source: &str) -> Result<Self> {
193        let mut sections = Self::default();
194        let mut current = None;
195
196        for (index, line) in source.lines().enumerate() {
197            let line_no = index + 1;
198            let trimmed = line.trim();
199            if trimmed.is_empty() || trimmed.starts_with('#') {
200                continue;
201            }
202            if let Some(section) = parse_section_header(trimmed, line_no)? {
203                current = Some(section);
204                continue;
205            }
206            let Some(section) = current else {
207                return Err(package_error(
208                    line_no,
209                    1,
210                    "package content must appear inside a named section",
211                ));
212            };
213            match section {
214                Section::Graph => sections.graph.push(line.to_owned()),
215                Section::Tests => sections.tests.push(trimmed.to_owned()),
216                Section::Metadata => sections.metadata.push(trimmed.to_owned()),
217                Section::Capabilities => sections.capabilities.push(PackageLine {
218                    line_no,
219                    text: trimmed.to_owned(),
220                }),
221            }
222        }
223
224        if sections.graph.is_empty() {
225            return Err(Error::Eval(
226                "topology package parse error: missing graph section".to_owned(),
227            ));
228        }
229
230        Ok(sections)
231    }
232}
233
234struct PackageLine {
235    line_no: usize,
236    text: String,
237}
238
239fn parse_section_header(trimmed: &str, line: usize) -> Result<Option<Section>> {
240    let Some(name) = trimmed.strip_suffix(':') else {
241        return Ok(None);
242    };
243    match name {
244        "graph" => Ok(Some(Section::Graph)),
245        "tests" => Ok(Some(Section::Tests)),
246        "metadata" => Ok(Some(Section::Metadata)),
247        "capabilities" => Ok(Some(Section::Capabilities)),
248        _ => Err(package_error(
249            line,
250            1,
251            format!("unknown package section {name}"),
252        )),
253    }
254}
255
256fn append_lines(output: &mut String, lines: &[String]) {
257    for line in lines {
258        output.push_str(line);
259        output.push('\n');
260    }
261}
262
263fn parse_capability_line(line_no: usize, text: &str) -> Result<CapabilityName> {
264    capability_name_from_text(text).map_err(|err| package_error(line_no, 1, err.to_string()))
265}
266
267fn expr_type(expr: &Expr) -> &'static str {
268    match expr {
269        Expr::Nil => "nil",
270        Expr::Bool(_) => "bool",
271        Expr::Number(_) => "number",
272        Expr::Symbol(_) => "symbol",
273        Expr::Local(_) => "local",
274        Expr::String(_) => "string",
275        Expr::Bytes(_) => "bytes",
276        Expr::List(_) => "list",
277        Expr::Vector(_) => "vector",
278        Expr::Map(_) => "map",
279        Expr::Set(_) => "set",
280        Expr::Call { .. } => "call",
281        Expr::Infix { .. } => "infix",
282        Expr::Prefix { .. } => "prefix",
283        Expr::Postfix { .. } => "postfix",
284        Expr::Block(_) => "block",
285        Expr::Quote { .. } => "quote",
286        Expr::Annotated { .. } => "annotated",
287        Expr::Extension { .. } => "extension",
288    }
289}
290
291fn package_error(line: usize, column: usize, message: impl Into<String>) -> Error {
292    Error::Eval(format!(
293        "topology package parse error at line {line}, column {column}: {}",
294        message.into()
295    ))
296}