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    PYTHON_MAIN
157        .replace("{{AGENT_NAME}}", &python_string(&config.metadata.name))
158        .replace(
159            "{{DESCRIPTION}}",
160            &python_string(config.metadata.description.as_deref().unwrap_or("")),
161        )
162        .replace("{{ADAPTER_ID}}", &python_string(&config.harness.adapter_id))
163        .replace(
164            "{{HARNESS_SETTINGS}}",
165            &python_value(&Value::Object(config.harness.settings.clone())),
166        )
167        .replace(
168            "{{INSTRUCTIONS}}",
169            &config
170                .instructions
171                .as_ref()
172                .and_then(|instructions| instructions.system.as_ref())
173                .map(|instruction| {
174                    format!(
175                        "InstructionsConfig(system=InstructionConfig(content={}, mode=\"replace\"))",
176                        python_string(&instruction.content)
177                    )
178                })
179                .unwrap_or_else(|| "None".to_string()),
180        )
181        .replace(
182            "{{MAX_TURNS}}",
183            &config
184                .runtime
185                .max_turns
186                .map(|value| value.to_string())
187                .unwrap_or_else(|| "None".to_string()),
188        )
189        .replace(
190            "{{TIMEOUT_SECONDS}}",
191            &config
192                .runtime
193                .timeout_seconds
194                .map(|value| value.to_string())
195                .unwrap_or_else(|| "None".to_string()),
196        )
197        .replace(
198            "{{ENVIRONMENT_ENV}}",
199            &python_value(&Value::Object(
200                config
201                    .environment
202                    .as_ref()
203                    .map(|environment| {
204                        environment
205                            .env
206                            .iter()
207                            .map(|(name, value)| (name.clone(), Value::String(value.clone())))
208                            .collect()
209                    })
210                    .unwrap_or_default(),
211            )),
212        )
213        .replace("{{MODELS}}", &python_models(config.models.get("default")))
214}
215
216fn python_models(model: Option<&ModelConfig>) -> String {
217    let Some(model) = model else {
218        return "{}".to_string();
219    };
220    let temperature = model
221        .temperature
222        .map(|value| value.to_string())
223        .unwrap_or_else(|| "None".to_string());
224    let base_url = model
225        .base_url
226        .as_deref()
227        .map(python_string)
228        .unwrap_or_else(|| "None".to_string());
229    format!(
230        "{{\"default\": ModelConfig(provider={}, model={}, temperature={temperature}, api_key_env={}, base_url={base_url}, settings={})}}",
231        python_string(&model.provider),
232        python_string(&model.model),
233        model
234            .api_key_env
235            .as_deref()
236            .map(python_string)
237            .unwrap_or_else(|| "None".to_string()),
238        python_value(&Value::Object(model.settings.clone())),
239    )
240}
241
242fn python_value(value: &Value) -> String {
243    match value {
244        Value::Null => "None".to_string(),
245        Value::Bool(value) => if *value { "True" } else { "False" }.to_string(),
246        Value::Number(value) => value.to_string(),
247        Value::String(value) => python_string(value),
248        Value::Array(values) => format!(
249            "[{}]",
250            values
251                .iter()
252                .map(python_value)
253                .collect::<Vec<_>>()
254                .join(", ")
255        ),
256        Value::Object(values) => format!(
257            "{{{}}}",
258            values
259                .iter()
260                .map(|(key, value)| format!("{}: {}", python_string(key), python_value(value)))
261                .collect::<Vec<_>>()
262                .join(", ")
263        ),
264    }
265}
266
267fn python_string(value: &str) -> String {
268    serde_json::to_string(value).expect("strings serialize")
269}
270
271fn render_rust(config: &FabricConfig) -> String {
272    RUST_MAIN
273        .replace("{{AGENT_NAME}}", &rust_string(&config.metadata.name))
274        .replace(
275            "{{DESCRIPTION}}",
276            &rust_string(config.metadata.description.as_deref().unwrap_or("")),
277        )
278        .replace("{{ADAPTER_ID}}", &rust_string(&config.harness.adapter_id))
279        .replace(
280            "{{HARNESS_SETTINGS}}",
281            &rust_settings(&config.harness.settings),
282        )
283        .replace(
284            "{{INSTRUCTIONS}}",
285            &config
286                .instructions
287                .as_ref()
288                .and_then(|instructions| instructions.system.as_ref())
289                .map(|instruction| {
290                    format!(
291                        "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() }})",
292                        rust_string(&instruction.content)
293                    )
294                })
295                .unwrap_or_else(|| "None".to_string()),
296        )
297        .replace(
298            "{{MAX_TURNS}}",
299            &config
300                .runtime
301                .max_turns
302                .map(|value| format!("Some({value})"))
303                .unwrap_or_else(|| "None".to_string()),
304        )
305        .replace(
306            "{{TIMEOUT_SECONDS}}",
307            &config
308                .runtime
309                .timeout_seconds
310                .map(|value| format!("Some({value})"))
311                .unwrap_or_else(|| "None".to_string()),
312        )
313        .replace(
314            "{{ENVIRONMENT_ENV}}",
315            &rust_string_map(
316                config
317                    .environment
318                    .as_ref()
319                    .map(|environment| &environment.env),
320            ),
321        )
322        .replace("{{MODELS}}", &rust_models(config.models.get("default")))
323}
324
325fn rust_string_map(values: Option<&std::collections::BTreeMap<String, String>>) -> String {
326    let Some(values) = values.filter(|values| !values.is_empty()) else {
327        return "BTreeMap::new()".to_string();
328    };
329    format!(
330        "BTreeMap::from_iter([{}])",
331        values
332            .iter()
333            .map(|(key, value)| format!(
334                "({}.to_string(), {}.to_string())",
335                rust_string(key),
336                rust_string(value)
337            ))
338            .collect::<Vec<_>>()
339            .join(", ")
340    )
341}
342
343fn rust_settings(settings: &serde_json::Map<String, Value>) -> String {
344    if settings.is_empty() {
345        return "Map::new()".to_string();
346    }
347    format!(
348        "Map::from_iter([{}])",
349        settings
350            .iter()
351            .map(|(key, value)| {
352                format!(
353                    "({}.to_string(), serde_json::json!({}))",
354                    rust_string(key),
355                    value
356                )
357            })
358            .collect::<Vec<_>>()
359            .join(", ")
360    )
361}
362
363fn rust_models(model: Option<&ModelConfig>) -> String {
364    let Some(model) = model else {
365        return "BTreeMap::new()".to_string();
366    };
367    let api_key = model
368        .api_key_env
369        .as_deref()
370        .map(|value| format!("Some({}.to_string())", rust_string(value)))
371        .unwrap_or_else(|| "None".to_string());
372    let temperature = model
373        .temperature
374        .map(|value| format!("Some({value})"))
375        .unwrap_or_else(|| "None".to_string());
376    let base_url = model
377        .base_url
378        .as_deref()
379        .map(|value| format!("Some({}.to_string())", rust_string(value)))
380        .unwrap_or_else(|| "None".to_string());
381    format!(
382        "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() }})])",
383        rust_string(&model.provider),
384        rust_string(&model.model),
385        rust_settings(&model.settings),
386    )
387}
388
389fn rust_string(value: &str) -> String {
390    format!("{value:?}")
391}
392
393fn package_name(example: &str) -> String {
394    example.replace('_', "-")
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::examples;
401
402    fn destination(test: &str, language: Language) -> PathBuf {
403        std::env::temp_dir().join(format!(
404            "nemo-fabric-scaffold-test-{}-{test}-{}",
405            std::process::id(),
406            language.as_str()
407        ))
408    }
409
410    #[test]
411    fn generates_both_languages_from_the_same_example_assets_and_config() {
412        let example = examples::find("code-review").expect("example");
413        for language in [Language::Python, Language::Rust] {
414            let destination = destination("generate", language);
415            let _ = fs::remove_dir_all(&destination);
416            init(example, Some("hermes"), language, &destination).expect("generate scaffold");
417            assert!(destination.join("repo/calculator.py").is_file());
418            assert!(destination.join("skills/code-review.md").is_file());
419            assert!(
420                destination
421                    .join("adapters/hermes/fabric-adapter.json")
422                    .is_file()
423            );
424            assert_eq!(
425                fs::read_to_string(destination.join("repo/calculator.py")).expect("read workspace"),
426                crate::examples::CODE_REVIEW_WORKSPACE
427            );
428            let launcher = match language {
429                Language::Python => destination.join("main.py"),
430                Language::Rust => destination.join("src/main.rs"),
431            };
432            let source = fs::read_to_string(launcher).expect("read launcher");
433            assert!(source.contains("nvidia.fabric.hermes"));
434            assert!(source.contains("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"));
435            assert!(source.contains("https://integrate.api.nvidia.com/v1"));
436            if language == Language::Rust {
437                let manifest =
438                    fs::read_to_string(destination.join("Cargo.toml")).expect("read manifest");
439                assert!(
440                    manifest.contains(&format!("nemo-fabric-core = {}", rust_core_dependency()))
441                );
442                assert!(manifest.contains("[workspace]"));
443            }
444            fs::remove_dir_all(destination).expect("remove scaffold");
445        }
446    }
447
448    #[test]
449    fn source_checkout_rust_scaffold_builds_inside_the_repository() {
450        let destination = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!(
451            ".nemo-fabric-scaffold-test-{}-build",
452            std::process::id()
453        ));
454        let _ = fs::remove_dir_all(&destination);
455        init(
456            examples::find("code-review").expect("example"),
457            None,
458            Language::Rust,
459            &destination,
460        )
461        .expect("generate scaffold");
462
463        let output = std::process::Command::new(env!("CARGO"))
464            .args(["check", "--offline"])
465            .current_dir(&destination)
466            .output()
467            .expect("run cargo check");
468        assert!(
469            output.status.success(),
470            "generated Rust scaffold did not build:\n{}",
471            String::from_utf8_lossy(&output.stderr)
472        );
473        fs::remove_dir_all(destination).expect("remove scaffold");
474    }
475
476    #[test]
477    fn rust_core_dependency_uses_local_checkout_when_available() {
478        let source_path = destination("core-dependency-local", Language::Rust);
479        let _ = fs::remove_dir_all(&source_path);
480        fs::create_dir_all(&source_path).expect("create source checkout");
481        fs::write(source_path.join("Cargo.toml"), "").expect("write Cargo manifest");
482        let canonical_path = source_path.canonicalize().expect("canonicalize checkout");
483
484        assert_eq!(
485            rust_core_dependency_for_path(&source_path),
486            format!(
487                "{{ path = {}, version = {} }}",
488                rust_string(canonical_path.to_str().expect("UTF-8 checkout path")),
489                rust_string(env!("CARGO_PKG_VERSION"))
490            )
491        );
492
493        fs::remove_dir_all(source_path).expect("remove source checkout");
494    }
495
496    #[test]
497    fn rust_core_dependency_falls_back_when_checkout_is_unavailable() {
498        let source_path = destination("core-dependency-missing", Language::Rust);
499        let _ = fs::remove_dir_all(&source_path);
500
501        assert_eq!(
502            rust_core_dependency_for_path(&source_path),
503            rust_string(env!("CARGO_PKG_VERSION"))
504        );
505    }
506
507    #[test]
508    fn renderers_preserve_model_settings_and_temperature() {
509        let mut config = presets::find("hermes")
510            .expect("hermes preset")
511            .config()
512            .expect("construct Hermes config");
513        config
514            .models
515            .get_mut("default")
516            .expect("default model")
517            .temperature = Some(0.2);
518
519        let python = render_python(&config);
520        assert!(python.contains("temperature=0.2"));
521        assert!(python.contains("base_url=\"https://integrate.api.nvidia.com/v1\""));
522
523        let rust = render_rust(&config);
524        assert!(rust.contains("temperature: Some(0.2)"));
525        assert!(
526            rust.contains("base_url: Some(\"https://integrate.api.nvidia.com/v1\".to_string())")
527        );
528    }
529
530    #[test]
531    fn refuses_to_overwrite_a_destination() {
532        let destination = destination("overwrite", Language::Python);
533        let _ = fs::remove_dir_all(&destination);
534        fs::create_dir(&destination).expect("create destination");
535        let error = init(
536            examples::find("code-review").expect("example"),
537            None,
538            Language::Python,
539            &destination,
540        )
541        .expect_err("must refuse overwrite");
542        assert!(error.contains("already exists"));
543        fs::remove_dir_all(destination).expect("remove destination");
544    }
545}