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