1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
30pub enum Language {
31 Python,
33 Rust,
35}
36
37impl Language {
38 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Python => "python",
42 Self::Rust => "rust",
43 }
44 }
45}
46
47pub 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}