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