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 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}