Skip to main content

nemo_fabric_cli/
scaffold.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Editable applications generated from the maintained example catalog.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use clap::ValueEnum;
10use nemo_fabric_core::{FabricConfig, ModelConfig};
11use serde_json::Value;
12
13use crate::examples::Example;
14use crate::presets;
15
16const PYTHON_MAIN: &str = include_str!("../templates/python/main.py.tmpl");
17const PYTHON_PROJECT: &str = include_str!("../templates/python/pyproject.toml.tmpl");
18const RUST_MAIN: &str = include_str!("../templates/rust/main.rs.tmpl");
19const RUST_PROJECT: &str = include_str!("../templates/rust/Cargo.toml.tmpl");
20const README: &str = include_str!("../templates/README.md.tmpl");
21
22#[derive(Debug)]
23struct ScaffoldFile {
24    path: String,
25    contents: String,
26}
27
28/// Language API used by a generated editable application.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
30pub enum Language {
31    /// Generate a Python SDK application.
32    Python,
33    /// Generate a direct Rust core application.
34    Rust,
35}
36
37impl Language {
38    /// Return the command-line spelling.
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Python => "python",
42            Self::Rust => "rust",
43        }
44    }
45}
46
47/// Generate an ordinary editable application and return its destination.
48pub fn init(
49    example: Example,
50    variant: Option<&str>,
51    language: Language,
52    destination: impl AsRef<Path>,
53) -> Result<PathBuf, String> {
54    let destination = destination.as_ref();
55    if destination.exists() {
56        return Err(format!(
57            "destination already exists: {}",
58            destination.display()
59        ));
60    }
61    let variant = variant.unwrap_or(example.default_variant);
62    let preset =
63        presets::find(variant).ok_or_else(|| format!("unknown preset variant {variant:?}"))?;
64    let config = example.config(Some(variant))?;
65    let mut files = example
66        .embedded_files(preset)
67        .into_iter()
68        .map(|file| ScaffoldFile {
69            path: file.path.to_string(),
70            contents: file.contents.to_string(),
71        })
72        .collect::<Vec<_>>();
73    files.extend(language_files(language, &config, example.name));
74    files.push(ScaffoldFile {
75        path: "README.md".to_string(),
76        contents: README
77            .replace("{{EXAMPLE}}", example.name)
78            .replace("{{VARIANT}}", variant)
79            .replace("{{LANGUAGE}}", language.as_str()),
80    });
81    write_files(destination, &files)?;
82    Ok(destination.to_path_buf())
83}
84
85fn language_files(language: Language, config: &FabricConfig, example: &str) -> Vec<ScaffoldFile> {
86    let (project_path, project, main_path, main) = match language {
87        Language::Python => (
88            "pyproject.toml",
89            PYTHON_PROJECT.replace("{{PACKAGE}}", &package_name(example)),
90            "main.py",
91            render_python(config),
92        ),
93        Language::Rust => (
94            "Cargo.toml",
95            RUST_PROJECT
96                .replace("{{PACKAGE}}", &package_name(example))
97                .replace("{{NEMO_FABRIC_CORE_DEPENDENCY}}", &rust_core_dependency()),
98            "src/main.rs",
99            render_rust(config),
100        ),
101    };
102    vec![
103        ScaffoldFile {
104            path: project_path.to_string(),
105            contents: project,
106        },
107        ScaffoldFile {
108            path: main_path.to_string(),
109            contents: main,
110        },
111    ]
112}
113
114fn rust_core_dependency() -> String {
115    let source_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../fabric-core");
116    rust_core_dependency_for_path(&source_path)
117}
118
119fn rust_core_dependency_for_path(source_path: &Path) -> String {
120    let version = rust_string(env!("CARGO_PKG_VERSION"));
121    if !source_path.join("Cargo.toml").is_file() {
122        return version;
123    }
124    let Ok(source_path) = source_path.canonicalize() else {
125        return version;
126    };
127    let Some(source_path) = source_path.to_str() else {
128        return version;
129    };
130    format!(
131        "{{ path = {}, version = {version} }}",
132        rust_string(source_path)
133    )
134}
135
136fn write_files(destination: &Path, files: &[ScaffoldFile]) -> Result<(), String> {
137    fs::create_dir(destination).map_err(|error| {
138        format!(
139            "failed to create destination {}: {error}",
140            destination.display()
141        )
142    })?;
143    for file in files {
144        let path = destination.join(&file.path);
145        if let Some(parent) = path.parent() {
146            fs::create_dir_all(parent)
147                .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
148        }
149        fs::write(&path, &file.contents)
150            .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
151    }
152    Ok(())
153}
154
155fn render_python(config: &FabricConfig) -> String {
156    let harness = config.harness.as_ref().expect("scaffold preset harness");
157    PYTHON_MAIN
158        .replace("{{AGENT_NAME}}", &python_string(&config.metadata.name))
159        .replace(
160            "{{DESCRIPTION}}",
161            &python_string(config.metadata.description.as_deref().unwrap_or("")),
162        )
163        .replace("{{ADAPTER_ID}}", &python_string(&harness.adapter_id))
164        .replace(
165            "{{HARNESS_SETTINGS}}",
166            &python_value(&Value::Object(harness.settings.clone())),
167        )
168        .replace(
169            "{{INSTRUCTIONS}}",
170            &config
171                .instructions
172                .as_ref()
173                .and_then(|instructions| instructions.system.as_ref())
174                .map(|instruction| {
175                    format!(
176                        "InstructionsConfig(system=InstructionConfig(content={}, mode=\"replace\"))",
177                        python_string(&instruction.content)
178                    )
179                })
180                .unwrap_or_else(|| "None".to_string()),
181        )
182        .replace(
183            "{{MAX_TURNS}}",
184            &config
185                .runtime
186                .max_turns
187                .map(|value| value.to_string())
188                .unwrap_or_else(|| "None".to_string()),
189        )
190        .replace(
191            "{{TIMEOUT_SECONDS}}",
192            &config
193                .runtime
194                .timeout_seconds
195                .map(|value| value.to_string())
196                .unwrap_or_else(|| "None".to_string()),
197        )
198        .replace(
199            "{{ENVIRONMENT_ENV}}",
200            &python_value(&Value::Object(
201                config
202                    .environment
203                    .as_ref()
204                    .map(|environment| {
205                        environment
206                            .env
207                            .iter()
208                            .map(|(name, value)| (name.clone(), Value::String(value.clone())))
209                            .collect()
210                    })
211                    .unwrap_or_default(),
212            )),
213        )
214        .replace("{{MODELS}}", &python_models(config.models.get("default")))
215}
216
217fn python_models(model: Option<&ModelConfig>) -> String {
218    let Some(model) = model else {
219        return "{}".to_string();
220    };
221    let temperature = model
222        .temperature
223        .map(|value| value.to_string())
224        .unwrap_or_else(|| "None".to_string());
225    let base_url = model
226        .base_url
227        .as_deref()
228        .map(python_string)
229        .unwrap_or_else(|| "None".to_string());
230    format!(
231        "{{\"default\": ModelConfig(provider={}, model={}, temperature={temperature}, api_key_env={}, base_url={base_url}, settings={})}}",
232        python_string(&model.provider),
233        python_string(&model.model),
234        model
235            .api_key_env
236            .as_deref()
237            .map(python_string)
238            .unwrap_or_else(|| "None".to_string()),
239        python_value(&Value::Object(model.settings.clone())),
240    )
241}
242
243fn python_value(value: &Value) -> String {
244    match value {
245        Value::Null => "None".to_string(),
246        Value::Bool(value) => if *value { "True" } else { "False" }.to_string(),
247        Value::Number(value) => value.to_string(),
248        Value::String(value) => python_string(value),
249        Value::Array(values) => format!(
250            "[{}]",
251            values
252                .iter()
253                .map(python_value)
254                .collect::<Vec<_>>()
255                .join(", ")
256        ),
257        Value::Object(values) => format!(
258            "{{{}}}",
259            values
260                .iter()
261                .map(|(key, value)| format!("{}: {}", python_string(key), python_value(value)))
262                .collect::<Vec<_>>()
263                .join(", ")
264        ),
265    }
266}
267
268fn python_string(value: &str) -> String {
269    serde_json::to_string(value).expect("strings serialize")
270}
271
272fn render_rust(config: &FabricConfig) -> String {
273    let harness = config.harness.as_ref().expect("scaffold preset harness");
274    RUST_MAIN
275        .replace("{{AGENT_NAME}}", &rust_string(&config.metadata.name))
276        .replace(
277            "{{DESCRIPTION}}",
278            &rust_string(config.metadata.description.as_deref().unwrap_or("")),
279        )
280        .replace("{{ADAPTER_ID}}", &rust_string(&harness.adapter_id))
281        .replace(
282            "{{HARNESS_SETTINGS}}",
283            &rust_settings(&harness.settings),
284        )
285        .replace(
286            "{{INSTRUCTIONS}}",
287            &config
288                .instructions
289                .as_ref()
290                .and_then(|instructions| instructions.system.as_ref())
291                .map(|instruction| {
292                    format!(
293                        "Some(nemo_fabric_core::InstructionsConfig {{ system: Some(nemo_fabric_core::InstructionConfig {{ content: {}.to_string(), mode: nemo_fabric_core::InstructionMode::Replace, extensions: BTreeMap::new() }}), extensions: BTreeMap::new() }})",
294                        rust_string(&instruction.content)
295                    )
296                })
297                .unwrap_or_else(|| "None".to_string()),
298        )
299        .replace(
300            "{{MAX_TURNS}}",
301            &config
302                .runtime
303                .max_turns
304                .map(|value| format!("Some({value})"))
305                .unwrap_or_else(|| "None".to_string()),
306        )
307        .replace(
308            "{{TIMEOUT_SECONDS}}",
309            &config
310                .runtime
311                .timeout_seconds
312                .map(|value| format!("Some({value})"))
313                .unwrap_or_else(|| "None".to_string()),
314        )
315        .replace(
316            "{{ENVIRONMENT_ENV}}",
317            &rust_string_map(
318                config
319                    .environment
320                    .as_ref()
321                    .map(|environment| &environment.env),
322            ),
323        )
324        .replace("{{MODELS}}", &rust_models(config.models.get("default")))
325}
326
327fn rust_string_map(values: Option<&std::collections::BTreeMap<String, String>>) -> String {
328    let Some(values) = values.filter(|values| !values.is_empty()) else {
329        return "BTreeMap::new()".to_string();
330    };
331    format!(
332        "BTreeMap::from_iter([{}])",
333        values
334            .iter()
335            .map(|(key, value)| format!(
336                "({}.to_string(), {}.to_string())",
337                rust_string(key),
338                rust_string(value)
339            ))
340            .collect::<Vec<_>>()
341            .join(", ")
342    )
343}
344
345fn rust_settings(settings: &serde_json::Map<String, Value>) -> String {
346    if settings.is_empty() {
347        return "Map::new()".to_string();
348    }
349    format!(
350        "Map::from_iter([{}])",
351        settings
352            .iter()
353            .map(|(key, value)| {
354                format!(
355                    "({}.to_string(), serde_json::json!({}))",
356                    rust_string(key),
357                    value
358                )
359            })
360            .collect::<Vec<_>>()
361            .join(", ")
362    )
363}
364
365fn rust_models(model: Option<&ModelConfig>) -> String {
366    let Some(model) = model else {
367        return "BTreeMap::new()".to_string();
368    };
369    let api_key = model
370        .api_key_env
371        .as_deref()
372        .map(|value| format!("Some({}.to_string())", rust_string(value)))
373        .unwrap_or_else(|| "None".to_string());
374    let temperature = model
375        .temperature
376        .map(|value| format!("Some({value})"))
377        .unwrap_or_else(|| "None".to_string());
378    let base_url = model
379        .base_url
380        .as_deref()
381        .map(|value| format!("Some({}.to_string())", rust_string(value)))
382        .unwrap_or_else(|| "None".to_string());
383    format!(
384        "BTreeMap::from_iter([(\"default\".to_string(), nemo_fabric_core::ModelConfig {{ provider: {}.to_string(), model: {}.to_string(), temperature: {temperature}, api_key_env: {api_key}, base_url: {base_url}, settings: {}, extensions: BTreeMap::new() }})])",
385        rust_string(&model.provider),
386        rust_string(&model.model),
387        rust_settings(&model.settings),
388    )
389}
390
391fn rust_string(value: &str) -> String {
392    format!("{value:?}")
393}
394
395fn package_name(example: &str) -> String {
396    example.replace('_', "-")
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::examples;
403
404    fn destination(test: &str, language: Language) -> PathBuf {
405        std::env::temp_dir().join(format!(
406            "nemo-fabric-scaffold-test-{}-{test}-{}",
407            std::process::id(),
408            language.as_str()
409        ))
410    }
411
412    #[test]
413    fn generates_both_languages_from_the_same_example_assets_and_config() {
414        let example = examples::find("code-review").expect("example");
415        for language in [Language::Python, Language::Rust] {
416            let destination = destination("generate", language);
417            let _ = fs::remove_dir_all(&destination);
418            init(example, Some("hermes"), language, &destination).expect("generate scaffold");
419            assert!(destination.join("repo/calculator.py").is_file());
420            assert!(destination.join("skills/code-review.md").is_file());
421            assert!(
422                destination
423                    .join("adapters/hermes/hermes.fabric-adapter.json")
424                    .is_file()
425            );
426            assert_eq!(
427                fs::read_to_string(destination.join("repo/calculator.py")).expect("read workspace"),
428                crate::examples::CODE_REVIEW_WORKSPACE
429            );
430            let launcher = match language {
431                Language::Python => destination.join("main.py"),
432                Language::Rust => destination.join("src/main.rs"),
433            };
434            let source = fs::read_to_string(launcher).expect("read launcher");
435            assert!(source.contains("nvidia.fabric.hermes"));
436            assert!(source.contains("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"));
437            assert!(source.contains("https://integrate.api.nvidia.com/v1"));
438            if language == Language::Rust {
439                let manifest =
440                    fs::read_to_string(destination.join("Cargo.toml")).expect("read manifest");
441                assert!(
442                    manifest.contains(&format!("nemo-fabric-core = {}", rust_core_dependency()))
443                );
444                assert!(manifest.contains("[workspace]"));
445            }
446            fs::remove_dir_all(destination).expect("remove scaffold");
447        }
448    }
449
450    #[test]
451    fn source_checkout_rust_scaffold_builds_inside_the_repository() {
452        let destination = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!(
453            ".nemo-fabric-scaffold-test-{}-build",
454            std::process::id()
455        ));
456        let _ = fs::remove_dir_all(&destination);
457        init(
458            examples::find("code-review").expect("example"),
459            None,
460            Language::Rust,
461            &destination,
462        )
463        .expect("generate scaffold");
464
465        let output = std::process::Command::new(env!("CARGO"))
466            .args(["check", "--offline"])
467            .current_dir(&destination)
468            .output()
469            .expect("run cargo check");
470        assert!(
471            output.status.success(),
472            "generated Rust scaffold did not build:\n{}",
473            String::from_utf8_lossy(&output.stderr)
474        );
475        fs::remove_dir_all(destination).expect("remove scaffold");
476    }
477
478    #[test]
479    fn rust_core_dependency_uses_local_checkout_when_available() {
480        let source_path = destination("core-dependency-local", Language::Rust);
481        let _ = fs::remove_dir_all(&source_path);
482        fs::create_dir_all(&source_path).expect("create source checkout");
483        fs::write(source_path.join("Cargo.toml"), "").expect("write Cargo manifest");
484        let canonical_path = source_path.canonicalize().expect("canonicalize checkout");
485
486        assert_eq!(
487            rust_core_dependency_for_path(&source_path),
488            format!(
489                "{{ path = {}, version = {} }}",
490                rust_string(canonical_path.to_str().expect("UTF-8 checkout path")),
491                rust_string(env!("CARGO_PKG_VERSION"))
492            )
493        );
494
495        fs::remove_dir_all(source_path).expect("remove source checkout");
496    }
497
498    #[test]
499    fn rust_core_dependency_falls_back_when_checkout_is_unavailable() {
500        let source_path = destination("core-dependency-missing", Language::Rust);
501        let _ = fs::remove_dir_all(&source_path);
502
503        assert_eq!(
504            rust_core_dependency_for_path(&source_path),
505            rust_string(env!("CARGO_PKG_VERSION"))
506        );
507    }
508
509    #[test]
510    fn renderers_preserve_model_settings_and_temperature() {
511        let mut config = presets::find("hermes")
512            .expect("hermes preset")
513            .config()
514            .expect("construct Hermes config");
515        config
516            .models
517            .get_mut("default")
518            .expect("default model")
519            .temperature = Some(0.2);
520
521        let python = render_python(&config);
522        assert!(python.contains("temperature=0.2"));
523        assert!(python.contains("base_url=\"https://integrate.api.nvidia.com/v1\""));
524
525        let rust = render_rust(&config);
526        assert!(rust.contains("temperature: Some(0.2)"));
527        assert!(
528            rust.contains("base_url: Some(\"https://integrate.api.nvidia.com/v1\".to_string())")
529        );
530    }
531
532    #[test]
533    fn refuses_to_overwrite_a_destination() {
534        let destination = destination("overwrite", Language::Python);
535        let _ = fs::remove_dir_all(&destination);
536        fs::create_dir(&destination).expect("create destination");
537        let error = init(
538            examples::find("code-review").expect("example"),
539            None,
540            Language::Python,
541            &destination,
542        )
543        .expect_err("must refuse overwrite");
544        assert!(error.contains("already exists"));
545        fs::remove_dir_all(destination).expect("remove destination");
546    }
547}