1pub mod plugin;
2pub mod progress;
3pub mod quantity;
4pub mod schema_source;
5pub mod validate;
6pub mod version_check;
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11
12use husako_runtime_qjs::ExecuteOptions;
13
14use progress::ProgressReporter;
15
16#[derive(Debug, thiserror::Error)]
17pub enum HusakoError {
18 #[error(transparent)]
19 Compile(#[from] husako_compile_oxc::CompileError),
20 #[error(transparent)]
21 Runtime(#[from] husako_runtime_qjs::RuntimeError),
22 #[error(transparent)]
23 Emit(#[from] husako_yaml::EmitError),
24 #[error(transparent)]
25 OpenApi(#[from] husako_openapi::OpenApiError),
26 #[error(transparent)]
27 Dts(#[from] husako_dts::DtsError),
28 #[error(transparent)]
29 Config(#[from] husako_config::ConfigError),
30 #[error(transparent)]
31 Chart(#[from] husako_helm::HelmError),
32 #[error("{0}")]
33 Validation(String),
34 #[error("generate I/O error: {0}")]
35 GenerateIo(String),
36}
37
38pub struct RenderOptions {
39 pub project_root: PathBuf,
40 pub allow_outside_root: bool,
41 pub schema_store: Option<validate::SchemaStore>,
42 pub timeout_ms: Option<u64>,
43 pub max_heap_mb: Option<usize>,
44 pub verbose: bool,
45}
46
47pub fn render(
48 source: &str,
49 filename: &str,
50 options: &RenderOptions,
51) -> Result<String, HusakoError> {
52 let js = husako_compile_oxc::compile(source, filename)?;
53
54 if options.verbose {
55 eprintln!(
56 "[compile] {} ({} bytes → {} bytes JS)",
57 filename,
58 source.len(),
59 js.len()
60 );
61 }
62
63 let entry_path = std::path::Path::new(filename)
64 .canonicalize()
65 .unwrap_or_else(|_| PathBuf::from(filename));
66
67 let generated_types_dir = options
68 .project_root
69 .join(".husako/types")
70 .canonicalize()
71 .ok();
72
73 let plugin_modules = load_plugin_modules(&options.project_root);
74
75 let exec_options = ExecuteOptions {
76 entry_path,
77 project_root: options.project_root.clone(),
78 allow_outside_root: options.allow_outside_root,
79 timeout_ms: options.timeout_ms,
80 max_heap_mb: options.max_heap_mb,
81 generated_types_dir,
82 plugin_modules,
83 };
84
85 if options.verbose {
86 eprintln!(
87 "[execute] QuickJS: timeout={}ms, heap={}MB",
88 options
89 .timeout_ms
90 .map_or("none".to_string(), |ms| ms.to_string()),
91 options
92 .max_heap_mb
93 .map_or("none".to_string(), |mb| mb.to_string()),
94 );
95 }
96
97 let execute_start = std::time::Instant::now();
98 let value = husako_runtime_qjs::execute(&js, &exec_options)?;
99
100 if options.verbose {
101 eprintln!("[execute] done ({}ms)", execute_start.elapsed().as_millis());
102 }
103
104 let validate_mode = if options.schema_store.is_some() {
105 "schema-based"
106 } else {
107 "fallback"
108 };
109 let doc_count = if let serde_json::Value::Array(arr) = &value {
110 arr.len()
111 } else {
112 1
113 };
114
115 if options.verbose {
116 eprintln!("[validate] {} documents, {}", doc_count, validate_mode);
117 }
118
119 let validate_start = std::time::Instant::now();
120 if let Err(errors) = validate::validate(&value, options.schema_store.as_ref()) {
121 let msg = errors
122 .iter()
123 .map(|e| e.to_string())
124 .collect::<Vec<_>>()
125 .join("\n");
126 return Err(HusakoError::Validation(msg));
127 }
128
129 if options.verbose {
130 eprintln!(
131 "[validate] done ({}ms), 0 errors",
132 validate_start.elapsed().as_millis()
133 );
134 }
135
136 let yaml = husako_yaml::emit_yaml(&value)?;
137
138 if options.verbose {
139 let line_count = yaml.lines().count();
140 eprintln!("[emit] {} documents ({} lines YAML)", doc_count, line_count);
141 }
142
143 Ok(yaml)
144}
145
146pub fn load_schema_store(project_root: &Path) -> Option<validate::SchemaStore> {
148 validate::load_schema_store(project_root)
149}
150
151pub struct GenerateOptions {
152 pub project_root: PathBuf,
153 pub openapi: Option<husako_openapi::FetchOptions>,
155 pub skip_k8s: bool,
156 pub config: Option<husako_config::HusakoConfig>,
158}
159
160pub fn generate(
161 options: &GenerateOptions,
162 progress: &dyn ProgressReporter,
163) -> Result<(), HusakoError> {
164 let types_dir = options.project_root.join(".husako/types");
165
166 let installed_plugins = if let Some(config) = &options.config
168 && !config.plugins.is_empty()
169 {
170 plugin::install_plugins(config, &options.project_root, progress)?
171 } else {
172 Vec::new()
173 };
174
175 let mut merged_config = options.config.clone();
177 if !installed_plugins.is_empty() && let Some(ref mut cfg) = merged_config {
178 plugin::merge_plugin_presets(cfg, &installed_plugins);
179 }
180
181 write_file(&types_dir.join("husako.d.ts"), husako_sdk::HUSAKO_DTS)?;
183
184 write_file(
186 &types_dir.join("husako/_base.d.ts"),
187 husako_sdk::HUSAKO_BASE_DTS,
188 )?;
189
190 if !options.skip_k8s {
193 let specs = if let Some(openapi_opts) = &options.openapi {
194 let task = progress.start_task("Fetching OpenAPI specs...");
196 let client = husako_openapi::OpenApiClient::new(husako_openapi::FetchOptions {
197 source: match &openapi_opts.source {
198 husako_openapi::OpenApiSource::Url {
199 base_url,
200 bearer_token,
201 } => husako_openapi::OpenApiSource::Url {
202 base_url: base_url.clone(),
203 bearer_token: bearer_token.clone(),
204 },
205 husako_openapi::OpenApiSource::Directory(p) => {
206 husako_openapi::OpenApiSource::Directory(p.clone())
207 }
208 },
209 cache_dir: options.project_root.join(".husako/cache"),
210 offline: openapi_opts.offline,
211 })?;
212 let result = client.fetch_all_specs()?;
213 task.finish_ok("Fetched OpenAPI specs");
214 Some(result)
215 } else if let Some(config) = &merged_config
216 && !config.resources.is_empty()
217 {
218 let cache_dir = options.project_root.join(".husako/cache");
220 Some(schema_source::resolve_all(
221 config,
222 &options.project_root,
223 &cache_dir,
224 progress,
225 )?)
226 } else {
227 None
228 };
229
230 if let Some(specs) = specs {
231 let task = progress.start_task("Generating types...");
232 let gen_options = husako_dts::GenerateOptions { specs };
233 let result = husako_dts::generate(&gen_options)?;
234
235 for (rel_path, content) in &result.files {
236 write_file(&types_dir.join(rel_path), content)?;
237 }
238 task.finish_ok("Generated k8s types");
239 }
240 }
241
242 if let Some(config) = &merged_config
244 && !config.charts.is_empty()
245 {
246 let cache_dir = options.project_root.join(".husako/cache");
247 let chart_schemas =
248 husako_helm::resolve_all(&config.charts, &options.project_root, &cache_dir)?;
249
250 for (chart_name, schema) in &chart_schemas {
251 let task = progress.start_task(&format!("Generating {chart_name} chart types..."));
252 let (dts, js) = husako_dts::json_schema::generate_chart_types(chart_name, schema)?;
253 write_file(&types_dir.join(format!("helm/{chart_name}.d.ts")), &dts)?;
254 write_file(&types_dir.join(format!("helm/{chart_name}.js")), &js)?;
255 task.finish_ok(&format!("{chart_name}: chart types generated"));
256 }
257 }
258
259 let plugin_paths = plugin::plugin_tsconfig_paths(&installed_plugins);
261 write_tsconfig(&options.project_root, merged_config.as_ref(), &plugin_paths)?;
262
263 Ok(())
264}
265
266fn write_file(path: &std::path::Path, content: &str) -> Result<(), HusakoError> {
267 if let Some(parent) = path.parent() {
268 std::fs::create_dir_all(parent).map_err(|e| {
269 HusakoError::GenerateIo(format!("create dir {}: {e}", parent.display()))
270 })?;
271 }
272 std::fs::write(path, content)
273 .map_err(|e| HusakoError::GenerateIo(format!("write {}: {e}", path.display())))
274}
275
276fn write_tsconfig(
277 project_root: &std::path::Path,
278 config: Option<&husako_config::HusakoConfig>,
279 plugin_paths: &std::collections::HashMap<String, String>,
280) -> Result<(), HusakoError> {
281 let tsconfig_path = project_root.join("tsconfig.json");
282
283 let mut paths = serde_json::json!({
284 "husako": [".husako/types/husako.d.ts"],
285 "husako/_base": [".husako/types/husako/_base.d.ts"],
286 "k8s/*": [".husako/types/k8s/*"]
287 });
288
289 if let Some(cfg) = config
291 && !cfg.charts.is_empty()
292 {
293 paths.as_object_mut().unwrap().insert(
294 "helm/*".to_string(),
295 serde_json::json!([".husako/types/helm/*"]),
296 );
297 }
298
299 for (specifier, dts_path) in plugin_paths {
301 paths.as_object_mut().unwrap().insert(
302 specifier.clone(),
303 serde_json::json!([dts_path]),
304 );
305 }
306
307 let husako_paths = paths;
308
309 let config = if tsconfig_path.exists() {
310 let content = std::fs::read_to_string(&tsconfig_path).map_err(|e| {
311 HusakoError::GenerateIo(format!("read {}: {e}", tsconfig_path.display()))
312 })?;
313
314 let stripped = strip_jsonc(&content);
315 match serde_json::from_str::<serde_json::Value>(&stripped) {
316 Ok(mut root) => {
317 let compiler_options = root
319 .as_object_mut()
320 .and_then(|obj| {
321 if !obj.contains_key("compilerOptions") {
322 obj.insert("compilerOptions".to_string(), serde_json::json!({}));
323 }
324 obj.get_mut("compilerOptions")
325 })
326 .and_then(|co| co.as_object_mut());
327
328 if let Some(co) = compiler_options {
329 co.entry("baseUrl")
330 .or_insert_with(|| serde_json::json!("."));
331
332 let paths = co.entry("paths").or_insert_with(|| serde_json::json!({}));
333 if let Some(paths_obj) = paths.as_object_mut()
334 && let Some(husako_obj) = husako_paths.as_object()
335 {
336 for (k, v) in husako_obj {
337 paths_obj.insert(k.clone(), v.clone());
338 }
339 }
340 }
341
342 root
343 }
344 Err(_) => {
345 eprintln!("warning: could not parse existing tsconfig.json, creating new one");
346 new_tsconfig(husako_paths)
347 }
348 }
349 } else {
350 new_tsconfig(husako_paths)
351 };
352
353 let formatted = serde_json::to_string_pretty(&config)
354 .map_err(|e| HusakoError::GenerateIo(format!("serialize tsconfig.json: {e}")))?;
355
356 std::fs::write(&tsconfig_path, formatted + "\n")
357 .map_err(|e| HusakoError::GenerateIo(format!("write {}: {e}", tsconfig_path.display())))
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum TemplateName {
364 Simple,
365 Project,
366 MultiEnv,
367}
368
369impl FromStr for TemplateName {
370 type Err = String;
371
372 fn from_str(s: &str) -> Result<Self, Self::Err> {
373 match s {
374 "simple" => Ok(Self::Simple),
375 "project" => Ok(Self::Project),
376 "multi-env" => Ok(Self::MultiEnv),
377 _ => Err(format!(
378 "unknown template '{s}'. Available: simple, project, multi-env"
379 )),
380 }
381 }
382}
383
384impl fmt::Display for TemplateName {
385 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386 match self {
387 Self::Simple => write!(f, "simple"),
388 Self::Project => write!(f, "project"),
389 Self::MultiEnv => write!(f, "multi-env"),
390 }
391 }
392}
393
394pub struct ScaffoldOptions {
395 pub directory: PathBuf,
396 pub template: TemplateName,
397 pub k8s_version: String,
398}
399
400pub fn scaffold(options: &ScaffoldOptions) -> Result<(), HusakoError> {
401 let dir = &options.directory;
402
403 if dir.exists() {
405 let is_empty = std::fs::read_dir(dir)
406 .map_err(|e| HusakoError::GenerateIo(format!("read dir {}: {e}", dir.display())))?
407 .next()
408 .is_none();
409 if !is_empty {
410 return Err(HusakoError::GenerateIo(format!(
411 "directory '{}' is not empty",
412 dir.display()
413 )));
414 }
415 }
416
417 std::fs::create_dir_all(dir)
419 .map_err(|e| HusakoError::GenerateIo(format!("create dir {}: {e}", dir.display())))?;
420
421 write_file(&dir.join(".gitignore"), husako_sdk::TEMPLATE_GITIGNORE)?;
423
424 let config_content = match options.template {
425 TemplateName::Simple => husako_sdk::TEMPLATE_SIMPLE_CONFIG,
426 TemplateName::Project => husako_sdk::TEMPLATE_PROJECT_CONFIG,
427 TemplateName::MultiEnv => husako_sdk::TEMPLATE_MULTI_ENV_CONFIG,
428 };
429 let config_content = config_content.replace("%K8S_VERSION%", &options.k8s_version);
430 write_file(&dir.join(husako_config::CONFIG_FILENAME), &config_content)?;
431
432 match options.template {
433 TemplateName::Simple => {
434 write_file(&dir.join("entry.ts"), husako_sdk::TEMPLATE_SIMPLE_ENTRY)?;
435 }
436 TemplateName::Project => {
437 write_file(
438 &dir.join("env/dev.ts"),
439 husako_sdk::TEMPLATE_PROJECT_ENV_DEV,
440 )?;
441 write_file(
442 &dir.join("deployments/nginx.ts"),
443 husako_sdk::TEMPLATE_PROJECT_DEPLOY_NGINX,
444 )?;
445 write_file(
446 &dir.join("lib/index.ts"),
447 husako_sdk::TEMPLATE_PROJECT_LIB_INDEX,
448 )?;
449 write_file(
450 &dir.join("lib/metadata.ts"),
451 husako_sdk::TEMPLATE_PROJECT_LIB_METADATA,
452 )?;
453 }
454 TemplateName::MultiEnv => {
455 write_file(
456 &dir.join("base/nginx.ts"),
457 husako_sdk::TEMPLATE_MULTI_ENV_BASE_NGINX,
458 )?;
459 write_file(
460 &dir.join("base/service.ts"),
461 husako_sdk::TEMPLATE_MULTI_ENV_BASE_SERVICE,
462 )?;
463 write_file(
464 &dir.join("dev/main.ts"),
465 husako_sdk::TEMPLATE_MULTI_ENV_DEV_MAIN,
466 )?;
467 write_file(
468 &dir.join("staging/main.ts"),
469 husako_sdk::TEMPLATE_MULTI_ENV_STAGING_MAIN,
470 )?;
471 write_file(
472 &dir.join("release/main.ts"),
473 husako_sdk::TEMPLATE_MULTI_ENV_RELEASE_MAIN,
474 )?;
475 }
476 }
477
478 Ok(())
479}
480
481#[derive(Debug)]
484pub struct InitOptions {
485 pub directory: PathBuf,
486 pub template: TemplateName,
487 pub k8s_version: String,
488}
489
490pub fn init(options: &InitOptions) -> Result<(), HusakoError> {
491 let dir = &options.directory;
492
493 if dir.join(husako_config::CONFIG_FILENAME).exists() {
495 return Err(HusakoError::GenerateIo(
496 "husako.toml already exists. Use 'husako new <dir>' to create a new project."
497 .to_string(),
498 ));
499 }
500
501 let gitignore_path = dir.join(".gitignore");
503 if gitignore_path.exists() {
504 let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
505 if !content.lines().any(|l| l.trim() == ".husako/") {
506 let mut appended = content;
507 if !appended.ends_with('\n') && !appended.is_empty() {
508 appended.push('\n');
509 }
510 appended.push_str(".husako/\n");
511 std::fs::write(&gitignore_path, appended).map_err(|e| {
512 HusakoError::GenerateIo(format!("write {}: {e}", gitignore_path.display()))
513 })?;
514 }
515 } else {
516 write_file(&gitignore_path, husako_sdk::TEMPLATE_GITIGNORE)?;
517 }
518
519 let config_content = match options.template {
520 TemplateName::Simple => husako_sdk::TEMPLATE_SIMPLE_CONFIG,
521 TemplateName::Project => husako_sdk::TEMPLATE_PROJECT_CONFIG,
522 TemplateName::MultiEnv => husako_sdk::TEMPLATE_MULTI_ENV_CONFIG,
523 };
524 let config_content = config_content.replace("%K8S_VERSION%", &options.k8s_version);
525 write_file(&dir.join(husako_config::CONFIG_FILENAME), &config_content)?;
526
527 match options.template {
528 TemplateName::Simple => {
529 let entry_path = dir.join("entry.ts");
530 if !entry_path.exists() {
531 write_file(&entry_path, husako_sdk::TEMPLATE_SIMPLE_ENTRY)?;
532 }
533 }
534 TemplateName::Project => {
535 let files = [
536 ("env/dev.ts", husako_sdk::TEMPLATE_PROJECT_ENV_DEV),
537 (
538 "deployments/nginx.ts",
539 husako_sdk::TEMPLATE_PROJECT_DEPLOY_NGINX,
540 ),
541 ("lib/index.ts", husako_sdk::TEMPLATE_PROJECT_LIB_INDEX),
542 ("lib/metadata.ts", husako_sdk::TEMPLATE_PROJECT_LIB_METADATA),
543 ];
544 for (path, content) in files {
545 let full_path = dir.join(path);
546 if !full_path.exists() {
547 write_file(&full_path, content)?;
548 }
549 }
550 }
551 TemplateName::MultiEnv => {
552 let files = [
553 ("base/nginx.ts", husako_sdk::TEMPLATE_MULTI_ENV_BASE_NGINX),
554 (
555 "base/service.ts",
556 husako_sdk::TEMPLATE_MULTI_ENV_BASE_SERVICE,
557 ),
558 ("dev/main.ts", husako_sdk::TEMPLATE_MULTI_ENV_DEV_MAIN),
559 (
560 "staging/main.ts",
561 husako_sdk::TEMPLATE_MULTI_ENV_STAGING_MAIN,
562 ),
563 (
564 "release/main.ts",
565 husako_sdk::TEMPLATE_MULTI_ENV_RELEASE_MAIN,
566 ),
567 ];
568 for (path, content) in files {
569 let full_path = dir.join(path);
570 if !full_path.exists() {
571 write_file(&full_path, content)?;
572 }
573 }
574 }
575 }
576
577 Ok(())
578}
579
580#[derive(Debug)]
583pub struct CleanOptions {
584 pub project_root: PathBuf,
585 pub cache: bool,
586 pub types: bool,
587}
588
589#[derive(Debug)]
590pub struct CleanResult {
591 pub cache_removed: bool,
592 pub types_removed: bool,
593 pub cache_size: u64,
594 pub types_size: u64,
595}
596
597pub fn clean(options: &CleanOptions) -> Result<CleanResult, HusakoError> {
598 let cache_dir = options.project_root.join(".husako/cache");
599 let types_dir = options.project_root.join(".husako/types");
600
601 let mut result = CleanResult {
602 cache_removed: false,
603 types_removed: false,
604 cache_size: 0,
605 types_size: 0,
606 };
607
608 if options.cache && cache_dir.exists() {
609 result.cache_size = dir_size(&cache_dir);
610 std::fs::remove_dir_all(&cache_dir)
611 .map_err(|e| HusakoError::GenerateIo(format!("remove {}: {e}", cache_dir.display())))?;
612 result.cache_removed = true;
613 }
614
615 if options.types && types_dir.exists() {
616 result.types_size = dir_size(&types_dir);
617 std::fs::remove_dir_all(&types_dir)
618 .map_err(|e| HusakoError::GenerateIo(format!("remove {}: {e}", types_dir.display())))?;
619 result.types_removed = true;
620 }
621
622 Ok(result)
623}
624
625fn dir_size(path: &Path) -> u64 {
626 walkdir(path).unwrap_or(0)
627}
628
629fn walkdir(path: &Path) -> Result<u64, std::io::Error> {
630 let mut total = 0;
631 if path.is_file() {
632 return Ok(path.metadata()?.len());
633 }
634 for entry in std::fs::read_dir(path)? {
635 let entry = entry?;
636 let meta = entry.metadata()?;
637 if meta.is_dir() {
638 total += walkdir(&entry.path())?;
639 } else {
640 total += meta.len();
641 }
642 }
643 Ok(total)
644}
645
646#[derive(Debug)]
649pub struct PluginInfo {
650 pub name: String,
651 pub version: String,
652 pub description: Option<String>,
653 pub module_count: usize,
654}
655
656#[derive(Debug)]
657pub struct DependencyList {
658 pub resources: Vec<DependencyInfo>,
659 pub charts: Vec<DependencyInfo>,
660 pub plugins: Vec<PluginInfo>,
661}
662
663#[derive(Debug)]
664pub struct DependencyInfo {
665 pub name: String,
666 pub source_type: &'static str,
667 pub version: Option<String>,
668 pub details: String,
669}
670
671pub fn list_dependencies(project_root: &Path) -> Result<DependencyList, HusakoError> {
672 let config = husako_config::load(project_root)?;
673
674 let mut resources = Vec::new();
675 let mut charts = Vec::new();
676 let mut plugins = Vec::new();
677
678 if let Some(cfg) = &config {
679 let mut res_entries: Vec<_> = cfg.resources.iter().collect();
680 res_entries.sort_by_key(|(k, _)| k.as_str());
681 for (name, source) in res_entries {
682 resources.push(resource_info(name, source));
683 }
684
685 let mut chart_entries: Vec<_> = cfg.charts.iter().collect();
686 chart_entries.sort_by_key(|(k, _)| k.as_str());
687 for (name, source) in chart_entries {
688 charts.push(chart_info(name, source));
689 }
690 }
691
692 for p in plugin::list_plugins(project_root) {
694 plugins.push(PluginInfo {
695 name: p.name,
696 version: p.manifest.plugin.version,
697 description: p.manifest.plugin.description,
698 module_count: p.manifest.modules.len(),
699 });
700 }
701
702 Ok(DependencyList {
703 resources,
704 charts,
705 plugins,
706 })
707}
708
709fn resource_info(name: &str, source: &husako_config::SchemaSource) -> DependencyInfo {
710 match source {
711 husako_config::SchemaSource::Release { version } => DependencyInfo {
712 name: name.to_string(),
713 source_type: "release",
714 version: Some(version.clone()),
715 details: String::new(),
716 },
717 husako_config::SchemaSource::Cluster { cluster } => DependencyInfo {
718 name: name.to_string(),
719 source_type: "cluster",
720 version: None,
721 details: cluster
722 .as_deref()
723 .map(|c| format!("cluster: {c}"))
724 .unwrap_or_default(),
725 },
726 husako_config::SchemaSource::Git { repo, tag, path } => DependencyInfo {
727 name: name.to_string(),
728 source_type: "git",
729 version: Some(tag.clone()),
730 details: format!("{repo} ({})", path),
731 },
732 husako_config::SchemaSource::File { path } => DependencyInfo {
733 name: name.to_string(),
734 source_type: "file",
735 version: None,
736 details: path.clone(),
737 },
738 }
739}
740
741fn chart_info(name: &str, source: &husako_config::ChartSource) -> DependencyInfo {
742 match source {
743 husako_config::ChartSource::Registry {
744 repo,
745 chart,
746 version,
747 } => DependencyInfo {
748 name: name.to_string(),
749 source_type: "registry",
750 version: Some(version.clone()),
751 details: format!("{repo} ({})", chart),
752 },
753 husako_config::ChartSource::ArtifactHub { package, version } => DependencyInfo {
754 name: name.to_string(),
755 source_type: "artifacthub",
756 version: Some(version.clone()),
757 details: package.clone(),
758 },
759 husako_config::ChartSource::File { path } => DependencyInfo {
760 name: name.to_string(),
761 source_type: "file",
762 version: None,
763 details: path.clone(),
764 },
765 husako_config::ChartSource::Git { repo, tag, path } => DependencyInfo {
766 name: name.to_string(),
767 source_type: "git",
768 version: Some(tag.clone()),
769 details: format!("{repo} ({})", path),
770 },
771 }
772}
773
774#[derive(Debug)]
777pub enum AddTarget {
778 Resource {
779 name: String,
780 source: husako_config::SchemaSource,
781 },
782 Chart {
783 name: String,
784 source: husako_config::ChartSource,
785 },
786}
787
788pub fn add_dependency(project_root: &Path, target: &AddTarget) -> Result<(), HusakoError> {
789 let (mut doc, path) = husako_config::edit::load_document(project_root)?;
790
791 match target {
792 AddTarget::Resource { name, source } => {
793 husako_config::edit::add_resource(&mut doc, name, source);
794 }
795 AddTarget::Chart { name, source } => {
796 husako_config::edit::add_chart(&mut doc, name, source);
797 }
798 }
799
800 husako_config::edit::save_document(&doc, &path)?;
801 Ok(())
802}
803
804#[derive(Debug)]
805pub struct RemoveResult {
806 pub name: String,
807 pub section: &'static str,
808}
809
810pub fn remove_dependency(project_root: &Path, name: &str) -> Result<RemoveResult, HusakoError> {
811 let (mut doc, path) = husako_config::edit::load_document(project_root)?;
812
813 if husako_config::edit::remove_resource(&mut doc, name) {
814 husako_config::edit::save_document(&doc, &path)?;
815 return Ok(RemoveResult {
816 name: name.to_string(),
817 section: "resources",
818 });
819 }
820
821 if husako_config::edit::remove_chart(&mut doc, name) {
822 husako_config::edit::save_document(&doc, &path)?;
823 return Ok(RemoveResult {
824 name: name.to_string(),
825 section: "charts",
826 });
827 }
828
829 Err(HusakoError::Config(husako_config::ConfigError::Validation(
830 format!("dependency '{name}' not found in [resources] or [charts]"),
831 )))
832}
833
834#[derive(Debug)]
837pub struct OutdatedEntry {
838 pub name: String,
839 pub kind: &'static str,
840 pub source_type: &'static str,
841 pub current: String,
842 pub latest: Option<String>,
843 pub up_to_date: bool,
844}
845
846pub fn check_outdated(
847 project_root: &Path,
848 progress: &dyn ProgressReporter,
849) -> Result<Vec<OutdatedEntry>, HusakoError> {
850 let config = husako_config::load(project_root)?;
851 let Some(cfg) = config else {
852 return Ok(Vec::new());
853 };
854
855 let mut entries = Vec::new();
856
857 for (name, source) in &cfg.resources {
858 match source {
859 husako_config::SchemaSource::Release { version } => {
860 let task = progress.start_task(&format!("Checking {name}..."));
861 match version_check::discover_latest_release() {
862 Ok(latest) => {
863 let up_to_date = version_check::versions_match(version, &latest);
864 task.finish_ok(&format!("{name}: {version} → {latest}"));
865 entries.push(OutdatedEntry {
866 name: name.clone(),
867 kind: "resource",
868 source_type: "release",
869 current: version.clone(),
870 latest: Some(latest),
871 up_to_date,
872 });
873 }
874 Err(e) => {
875 task.finish_err(&format!("{name}: {e}"));
876 entries.push(OutdatedEntry {
877 name: name.clone(),
878 kind: "resource",
879 source_type: "release",
880 current: version.clone(),
881 latest: None,
882 up_to_date: false,
883 });
884 }
885 }
886 }
887 husako_config::SchemaSource::Git { tag, repo, .. } => {
888 let task = progress.start_task(&format!("Checking {name}..."));
889 match version_check::discover_latest_git_tag(repo) {
890 Ok(Some(latest)) => {
891 let up_to_date = tag == &latest;
892 task.finish_ok(&format!("{name}: {tag} → {latest}"));
893 entries.push(OutdatedEntry {
894 name: name.clone(),
895 kind: "resource",
896 source_type: "git",
897 current: tag.clone(),
898 latest: Some(latest),
899 up_to_date,
900 });
901 }
902 Ok(None) => {
903 task.finish_ok(&format!("{name}: no tags"));
904 entries.push(OutdatedEntry {
905 name: name.clone(),
906 kind: "resource",
907 source_type: "git",
908 current: tag.clone(),
909 latest: None,
910 up_to_date: false,
911 });
912 }
913 Err(e) => {
914 task.finish_err(&format!("{name}: {e}"));
915 entries.push(OutdatedEntry {
916 name: name.clone(),
917 kind: "resource",
918 source_type: "git",
919 current: tag.clone(),
920 latest: None,
921 up_to_date: false,
922 });
923 }
924 }
925 }
926 _ => {}
928 }
929 }
930
931 for (name, source) in &cfg.charts {
932 match source {
933 husako_config::ChartSource::Registry {
934 repo,
935 chart,
936 version,
937 } => {
938 let task = progress.start_task(&format!("Checking {name}..."));
939 match version_check::discover_latest_registry(repo, chart) {
940 Ok(latest) => {
941 let up_to_date = version == &latest;
942 task.finish_ok(&format!("{name}: {version} → {latest}"));
943 entries.push(OutdatedEntry {
944 name: name.clone(),
945 kind: "chart",
946 source_type: "registry",
947 current: version.clone(),
948 latest: Some(latest),
949 up_to_date,
950 });
951 }
952 Err(e) => {
953 task.finish_err(&format!("{name}: {e}"));
954 entries.push(OutdatedEntry {
955 name: name.clone(),
956 kind: "chart",
957 source_type: "registry",
958 current: version.clone(),
959 latest: None,
960 up_to_date: false,
961 });
962 }
963 }
964 }
965 husako_config::ChartSource::ArtifactHub { package, version } => {
966 let task = progress.start_task(&format!("Checking {name}..."));
967 match version_check::discover_latest_artifacthub(package) {
968 Ok(latest) => {
969 let up_to_date = version == &latest;
970 task.finish_ok(&format!("{name}: {version} → {latest}"));
971 entries.push(OutdatedEntry {
972 name: name.clone(),
973 kind: "chart",
974 source_type: "artifacthub",
975 current: version.clone(),
976 latest: Some(latest),
977 up_to_date,
978 });
979 }
980 Err(e) => {
981 task.finish_err(&format!("{name}: {e}"));
982 entries.push(OutdatedEntry {
983 name: name.clone(),
984 kind: "chart",
985 source_type: "artifacthub",
986 current: version.clone(),
987 latest: None,
988 up_to_date: false,
989 });
990 }
991 }
992 }
993 husako_config::ChartSource::Git { tag, repo, .. } => {
994 let task = progress.start_task(&format!("Checking {name}..."));
995 match version_check::discover_latest_git_tag(repo) {
996 Ok(Some(latest)) => {
997 let up_to_date = tag == &latest;
998 task.finish_ok(&format!("{name}: {tag} → {latest}"));
999 entries.push(OutdatedEntry {
1000 name: name.clone(),
1001 kind: "chart",
1002 source_type: "git",
1003 current: tag.clone(),
1004 latest: Some(latest),
1005 up_to_date,
1006 });
1007 }
1008 Ok(None) => {
1009 task.finish_ok(&format!("{name}: no tags"));
1010 }
1011 Err(e) => {
1012 task.finish_err(&format!("{name}: {e}"));
1013 entries.push(OutdatedEntry {
1014 name: name.clone(),
1015 kind: "chart",
1016 source_type: "git",
1017 current: tag.clone(),
1018 latest: None,
1019 up_to_date: false,
1020 });
1021 }
1022 }
1023 }
1024 _ => {}
1026 }
1027 }
1028
1029 Ok(entries)
1030}
1031
1032#[derive(Debug)]
1035pub struct UpdateOptions {
1036 pub project_root: PathBuf,
1037 pub name: Option<String>,
1038 pub resources_only: bool,
1039 pub charts_only: bool,
1040 pub dry_run: bool,
1041}
1042
1043#[derive(Debug)]
1044pub struct UpdatedEntry {
1045 pub name: String,
1046 pub kind: &'static str,
1047 pub old_version: String,
1048 pub new_version: String,
1049}
1050
1051#[derive(Debug)]
1052pub struct UpdateResult {
1053 pub updated: Vec<UpdatedEntry>,
1054 pub skipped: Vec<String>,
1055 pub failed: Vec<(String, String)>,
1056}
1057
1058pub fn update_dependencies(
1059 options: &UpdateOptions,
1060 progress: &dyn ProgressReporter,
1061) -> Result<UpdateResult, HusakoError> {
1062 let outdated = check_outdated(&options.project_root, progress)?;
1063
1064 let mut result = UpdateResult {
1065 updated: Vec::new(),
1066 skipped: Vec::new(),
1067 failed: Vec::new(),
1068 };
1069
1070 let filtered: Vec<_> = outdated
1072 .into_iter()
1073 .filter(|e| {
1074 if let Some(ref target) = options.name {
1075 return &e.name == target;
1076 }
1077 if options.resources_only && e.kind != "resource" {
1078 return false;
1079 }
1080 if options.charts_only && e.kind != "chart" {
1081 return false;
1082 }
1083 true
1084 })
1085 .collect();
1086
1087 let mut doc_and_path = None;
1088
1089 for entry in filtered {
1090 let Some(ref latest) = entry.latest else {
1091 result
1092 .failed
1093 .push((entry.name, "could not determine latest version".to_string()));
1094 continue;
1095 };
1096
1097 if entry.up_to_date {
1098 result.skipped.push(entry.name);
1099 continue;
1100 }
1101
1102 if options.dry_run {
1103 result.updated.push(UpdatedEntry {
1104 name: entry.name,
1105 kind: entry.kind,
1106 old_version: entry.current,
1107 new_version: latest.clone(),
1108 });
1109 continue;
1110 }
1111
1112 if doc_and_path.is_none() {
1114 doc_and_path = Some(husako_config::edit::load_document(&options.project_root)?);
1115 }
1116 let (doc, _) = doc_and_path.as_mut().unwrap();
1117
1118 let updated = if entry.kind == "resource" {
1119 husako_config::edit::update_resource_version(doc, &entry.name, latest)
1120 } else {
1121 husako_config::edit::update_chart_version(doc, &entry.name, latest)
1122 };
1123
1124 if updated {
1125 result.updated.push(UpdatedEntry {
1126 name: entry.name,
1127 kind: entry.kind,
1128 old_version: entry.current,
1129 new_version: latest.clone(),
1130 });
1131 }
1132 }
1133
1134 if let Some((doc, path)) = &doc_and_path
1136 && !result.updated.is_empty()
1137 {
1138 husako_config::edit::save_document(doc, path)?;
1139 }
1140
1141 if !options.dry_run && !result.updated.is_empty() {
1143 let task = progress.start_task("Regenerating types...");
1144 let config = husako_config::load(&options.project_root)?;
1145 let gen_options = GenerateOptions {
1146 project_root: options.project_root.clone(),
1147 openapi: None,
1148 skip_k8s: false,
1149 config,
1150 };
1151 match generate(&gen_options, progress) {
1152 Ok(()) => task.finish_ok("Types regenerated"),
1153 Err(e) => task.finish_err(&format!("Type generation failed: {e}")),
1154 }
1155 }
1156
1157 Ok(result)
1158}
1159
1160#[derive(Debug)]
1163pub struct ProjectSummary {
1164 pub project_root: PathBuf,
1165 pub config_valid: bool,
1166 pub resources: Vec<DependencyInfo>,
1167 pub charts: Vec<DependencyInfo>,
1168 pub cache_size: u64,
1169 pub type_file_count: usize,
1170 pub types_size: u64,
1171}
1172
1173pub fn project_summary(project_root: &Path) -> Result<ProjectSummary, HusakoError> {
1174 let config = husako_config::load(project_root);
1175 let config_valid = config.is_ok();
1176
1177 let deps = list_dependencies(project_root).unwrap_or(DependencyList {
1178 resources: Vec::new(),
1179 charts: Vec::new(),
1180 plugins: Vec::new(),
1181 });
1182
1183 let cache_dir = project_root.join(".husako/cache");
1184 let types_dir = project_root.join(".husako/types");
1185
1186 let cache_size = if cache_dir.exists() {
1187 dir_size(&cache_dir)
1188 } else {
1189 0
1190 };
1191
1192 let (type_file_count, types_size) = if types_dir.exists() {
1193 count_files_and_size(&types_dir)
1194 } else {
1195 (0, 0)
1196 };
1197
1198 Ok(ProjectSummary {
1199 project_root: project_root.to_path_buf(),
1200 config_valid,
1201 resources: deps.resources,
1202 charts: deps.charts,
1203 cache_size,
1204 type_file_count,
1205 types_size,
1206 })
1207}
1208
1209#[derive(Debug)]
1210pub struct DependencyDetail {
1211 pub info: DependencyInfo,
1212 pub cache_path: Option<PathBuf>,
1213 pub cache_size: u64,
1214 pub type_files: Vec<(PathBuf, u64)>,
1215 pub schema_property_count: Option<(usize, usize)>,
1216 pub group_versions: Vec<(String, Vec<String>)>,
1217}
1218
1219pub fn dependency_detail(project_root: &Path, name: &str) -> Result<DependencyDetail, HusakoError> {
1220 let config = husako_config::load(project_root)?;
1221 let Some(cfg) = config else {
1222 return Err(HusakoError::Config(husako_config::ConfigError::Validation(
1223 "no husako.toml found".to_string(),
1224 )));
1225 };
1226
1227 if let Some(source) = cfg.resources.get(name) {
1229 let info = resource_info(name, source);
1230 let types_dir = project_root.join(".husako/types/k8s");
1231 let type_files = list_type_files(&types_dir);
1232
1233 let group_versions = read_group_versions(&types_dir);
1235
1236 let (cache_path, cache_size) = resource_cache_info(source, project_root);
1237
1238 return Ok(DependencyDetail {
1239 info,
1240 cache_path,
1241 cache_size,
1242 type_files,
1243 schema_property_count: None,
1244 group_versions,
1245 });
1246 }
1247
1248 if let Some(source) = cfg.charts.get(name) {
1250 let info = chart_info(name, source);
1251 let types_dir = project_root.join(".husako/types/helm");
1252 let type_files = list_chart_type_files(&types_dir, name);
1253 let schema_property_count = read_chart_schema_props(project_root, name);
1254 let (cache_path, cache_size) = chart_cache_info(source, project_root);
1255
1256 return Ok(DependencyDetail {
1257 info,
1258 cache_path,
1259 cache_size,
1260 type_files,
1261 schema_property_count,
1262 group_versions: Vec::new(),
1263 });
1264 }
1265
1266 Err(HusakoError::Config(husako_config::ConfigError::Validation(
1267 format!("dependency '{name}' not found"),
1268 )))
1269}
1270
1271fn count_files_and_size(dir: &Path) -> (usize, u64) {
1272 let mut count = 0;
1273 let mut size = 0;
1274 if let Ok(entries) = std::fs::read_dir(dir) {
1275 for entry in entries.flatten() {
1276 let meta = entry.metadata();
1277 if let Ok(m) = meta {
1278 if m.is_dir() {
1279 let (c, s) = count_files_and_size(&entry.path());
1280 count += c;
1281 size += s;
1282 } else {
1283 count += 1;
1284 size += m.len();
1285 }
1286 }
1287 }
1288 }
1289 (count, size)
1290}
1291
1292fn list_type_files(dir: &Path) -> Vec<(PathBuf, u64)> {
1293 let mut files = Vec::new();
1294 if let Ok(entries) = std::fs::read_dir(dir) {
1295 for entry in entries.flatten() {
1296 if let Ok(meta) = entry.metadata()
1297 && meta.is_file()
1298 {
1299 files.push((entry.path(), meta.len()));
1300 }
1301 }
1302 }
1303 files.sort_by(|a, b| a.0.cmp(&b.0));
1304 files
1305}
1306
1307fn list_chart_type_files(dir: &Path, chart_name: &str) -> Vec<(PathBuf, u64)> {
1308 let mut files = Vec::new();
1309 for ext in ["d.ts", "js"] {
1310 let path = dir.join(format!("{chart_name}.{ext}"));
1311 if let Ok(meta) = path.metadata() {
1312 files.push((path, meta.len()));
1313 }
1314 }
1315 files
1316}
1317
1318fn read_group_versions(types_dir: &Path) -> Vec<(String, Vec<String>)> {
1319 let mut gvs: Vec<(String, Vec<String>)> = Vec::new();
1320 if let Ok(entries) = std::fs::read_dir(types_dir) {
1321 for entry in entries.flatten() {
1322 let path = entry.path();
1323 if path.extension().is_some_and(|e| e == "ts")
1324 && path
1325 .file_name()
1326 .is_some_and(|n| n.to_string_lossy().ends_with(".d.ts"))
1327 {
1328 let stem = path
1329 .file_stem()
1330 .unwrap()
1331 .to_string_lossy()
1332 .trim_end_matches(".d")
1333 .to_string();
1334 let gv = stem.replace("__", "/");
1335 gvs.push((gv, Vec::new()));
1336 }
1337 }
1338 }
1339 gvs.sort_by(|a, b| a.0.cmp(&b.0));
1340 gvs
1341}
1342
1343fn resource_cache_info(
1344 source: &husako_config::SchemaSource,
1345 project_root: &Path,
1346) -> (Option<PathBuf>, u64) {
1347 let cache_base = project_root.join(".husako/cache");
1348 match source {
1349 husako_config::SchemaSource::Release { version } => {
1350 let path = cache_base.join(format!("release/v{version}.0"));
1351 let size = if path.exists() { dir_size(&path) } else { 0 };
1352 (Some(path), size)
1353 }
1354 _ => (None, 0),
1355 }
1356}
1357
1358fn chart_cache_info(
1359 _source: &husako_config::ChartSource,
1360 _project_root: &Path,
1361) -> (Option<PathBuf>, u64) {
1362 (None, 0)
1363}
1364
1365fn read_chart_schema_props(project_root: &Path, chart_name: &str) -> Option<(usize, usize)> {
1366 let dts_path = project_root.join(format!(".husako/types/helm/{chart_name}.d.ts"));
1367 if !dts_path.exists() {
1368 return None;
1369 }
1370 let content = std::fs::read_to_string(&dts_path).ok()?;
1371 let total = content.matches("?: ").count() + content.matches(": ").count();
1373 let top_level = content
1374 .lines()
1375 .filter(|l| {
1376 l.starts_with(" ")
1377 && !l.starts_with(" ")
1378 && (l.contains("?: ") || l.contains(": "))
1379 && !l.contains("export")
1380 && !l.contains("class")
1381 && !l.contains("interface")
1382 })
1383 .count();
1384 Some((total, top_level))
1385}
1386
1387#[derive(Debug)]
1390pub struct DebugReport {
1391 pub config_ok: Option<bool>,
1392 pub types_exist: bool,
1393 pub type_file_count: usize,
1394 pub tsconfig_ok: bool,
1395 pub tsconfig_has_paths: bool,
1396 pub stale: bool,
1397 pub cache_size: u64,
1398 pub issues: Vec<String>,
1399 pub suggestions: Vec<String>,
1400}
1401
1402pub fn debug_project(project_root: &Path) -> Result<DebugReport, HusakoError> {
1403 let config_path = project_root.join(husako_config::CONFIG_FILENAME);
1404 let types_dir = project_root.join(".husako/types");
1405 let cache_dir = project_root.join(".husako/cache");
1406 let tsconfig_path = project_root.join("tsconfig.json");
1407
1408 let mut issues = Vec::new();
1409 let mut suggestions = Vec::new();
1410
1411 let config_ok = if config_path.exists() {
1413 match husako_config::load(project_root) {
1414 Ok(_) => Some(true),
1415 Err(e) => {
1416 issues.push(format!("husako.toml parse error: {e}"));
1417 Some(false)
1418 }
1419 }
1420 } else {
1421 issues.push("husako.toml not found".to_string());
1422 suggestions.push("Run 'husako init' to initialize a project".to_string());
1423 None
1424 };
1425
1426 let types_exist = types_dir.exists();
1428 let (type_file_count, _) = if types_exist {
1429 count_files_and_size(&types_dir)
1430 } else {
1431 issues.push(".husako/types/ directory not found".to_string());
1432 suggestions.push("Run 'husako generate' to create type definitions".to_string());
1433 (0, 0)
1434 };
1435
1436 let (tsconfig_ok, tsconfig_has_paths) = if tsconfig_path.exists() {
1438 let content = std::fs::read_to_string(&tsconfig_path).unwrap_or_default();
1439 let stripped = strip_jsonc(&content);
1440 match serde_json::from_str::<serde_json::Value>(&stripped) {
1441 Ok(parsed) => {
1442 let has_husako = parsed.pointer("/compilerOptions/paths/husako").is_some();
1443 let has_k8s = parsed.pointer("/compilerOptions/paths/k8s~1*").is_some();
1444 if !has_husako && !has_k8s {
1445 issues.push("tsconfig.json is missing husako path mappings".to_string());
1446 suggestions.push("Run 'husako generate' to update tsconfig.json".to_string());
1447 }
1448 (true, has_husako || has_k8s)
1449 }
1450 Err(_) => {
1451 issues.push("tsconfig.json could not be parsed".to_string());
1452 (false, false)
1453 }
1454 }
1455 } else {
1456 issues.push("tsconfig.json not found".to_string());
1457 suggestions.push("Run 'husako generate' to create tsconfig.json".to_string());
1458 (false, false)
1459 };
1460
1461 let stale = if config_path.exists() && types_dir.exists() {
1463 let config_mtime = config_path.metadata().and_then(|m| m.modified()).ok();
1464 let types_mtime = types_dir.metadata().and_then(|m| m.modified()).ok();
1465 match (config_mtime, types_mtime) {
1466 (Some(c), Some(t)) if c > t => {
1467 issues
1468 .push("Types may be stale (husako.toml newer than .husako/types/)".to_string());
1469 suggestions.push("Run 'husako generate' to update".to_string());
1470 true
1471 }
1472 _ => false,
1473 }
1474 } else {
1475 false
1476 };
1477
1478 let cache_size = if cache_dir.exists() {
1480 dir_size(&cache_dir)
1481 } else {
1482 0
1483 };
1484
1485 Ok(DebugReport {
1486 config_ok,
1487 types_exist,
1488 type_file_count,
1489 tsconfig_ok,
1490 tsconfig_has_paths,
1491 stale,
1492 cache_size,
1493 issues,
1494 suggestions,
1495 })
1496}
1497
1498#[derive(Debug)]
1501pub struct ValidateResult {
1502 pub resource_count: usize,
1503 pub validation_errors: Vec<String>,
1504}
1505
1506pub fn validate_file(
1507 source: &str,
1508 filename: &str,
1509 options: &RenderOptions,
1510) -> Result<ValidateResult, HusakoError> {
1511 let js = husako_compile_oxc::compile(source, filename)?;
1512
1513 let entry_path = std::path::Path::new(filename)
1514 .canonicalize()
1515 .unwrap_or_else(|_| PathBuf::from(filename));
1516
1517 let generated_types_dir = options
1518 .project_root
1519 .join(".husako/types")
1520 .canonicalize()
1521 .ok();
1522
1523 let plugin_modules = load_plugin_modules(&options.project_root);
1524
1525 let exec_options = ExecuteOptions {
1526 entry_path,
1527 project_root: options.project_root.clone(),
1528 allow_outside_root: options.allow_outside_root,
1529 timeout_ms: options.timeout_ms,
1530 max_heap_mb: options.max_heap_mb,
1531 generated_types_dir,
1532 plugin_modules,
1533 };
1534
1535 let value = husako_runtime_qjs::execute(&js, &exec_options)?;
1536
1537 let resource_count = if let serde_json::Value::Array(arr) = &value {
1538 arr.len()
1539 } else {
1540 1
1541 };
1542
1543 let validation_errors =
1544 if let Err(errors) = validate::validate(&value, options.schema_store.as_ref()) {
1545 errors.iter().map(|e| e.to_string()).collect()
1546 } else {
1547 Vec::new()
1548 };
1549
1550 if !validation_errors.is_empty() {
1551 return Err(HusakoError::Validation(validation_errors.join("\n")));
1552 }
1553
1554 Ok(ValidateResult {
1555 resource_count,
1556 validation_errors,
1557 })
1558}
1559
1560fn strip_jsonc(input: &str) -> String {
1566 let mut out = String::with_capacity(input.len());
1567 let chars: Vec<char> = input.chars().collect();
1568 let len = chars.len();
1569 let mut i = 0;
1570
1571 while i < len {
1572 if chars[i] == '"' {
1574 out.push('"');
1575 i += 1;
1576 while i < len {
1577 if chars[i] == '\\' && i + 1 < len {
1578 out.push(chars[i]);
1579 out.push(chars[i + 1]);
1580 i += 2;
1581 } else if chars[i] == '"' {
1582 out.push('"');
1583 i += 1;
1584 break;
1585 } else {
1586 out.push(chars[i]);
1587 i += 1;
1588 }
1589 }
1590 continue;
1591 }
1592
1593 if chars[i] == '/' && i + 1 < len && chars[i + 1] == '/' {
1595 i += 2;
1596 while i < len && chars[i] != '\n' {
1597 i += 1;
1598 }
1599 continue;
1600 }
1601
1602 if chars[i] == '/' && i + 1 < len && chars[i + 1] == '*' {
1604 i += 2;
1605 while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
1606 i += 1;
1607 }
1608 if i + 1 < len {
1609 i += 2; }
1611 continue;
1612 }
1613
1614 if chars[i] == ',' {
1616 let mut j = i + 1;
1617 while j < len && chars[j].is_ascii_whitespace() {
1618 j += 1;
1619 }
1620 if j < len && (chars[j] == '}' || chars[j] == ']') {
1621 i += 1;
1623 continue;
1624 }
1625 }
1626
1627 out.push(chars[i]);
1628 i += 1;
1629 }
1630
1631 out
1632}
1633
1634fn load_plugin_modules(
1639 project_root: &Path,
1640) -> std::collections::HashMap<String, PathBuf> {
1641 let mut modules = std::collections::HashMap::new();
1642 let plugins_dir = project_root.join(".husako/plugins");
1643 if !plugins_dir.is_dir() {
1644 return modules;
1645 }
1646
1647 let Ok(entries) = std::fs::read_dir(&plugins_dir) else {
1648 return modules;
1649 };
1650
1651 for entry in entries.flatten() {
1652 let plugin_dir = entry.path();
1653 if !plugin_dir.is_dir() {
1654 continue;
1655 }
1656 let Ok(manifest) = husako_config::load_plugin_manifest(&plugin_dir) else {
1657 continue;
1658 };
1659 for (specifier, rel_path) in &manifest.modules {
1660 let abs_path = plugin_dir.join(rel_path);
1661 modules.insert(specifier.clone(), abs_path);
1662 }
1663 }
1664
1665 modules
1666}
1667
1668fn new_tsconfig(husako_paths: serde_json::Value) -> serde_json::Value {
1669 serde_json::json!({
1670 "compilerOptions": {
1671 "strict": true,
1672 "module": "ESNext",
1673 "moduleResolution": "bundler",
1674 "baseUrl": ".",
1675 "paths": husako_paths
1676 }
1677 })
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682 use super::*;
1683
1684 fn test_options() -> RenderOptions {
1685 RenderOptions {
1686 project_root: PathBuf::from("/tmp"),
1687 allow_outside_root: false,
1688 schema_store: None,
1689 timeout_ms: None,
1690 max_heap_mb: None,
1691 verbose: false,
1692 }
1693 }
1694
1695 #[test]
1696 fn end_to_end_render() {
1697 let ts = r#"
1698 import { build } from "husako";
1699 build([{ _render() { return { apiVersion: "v1", kind: "Namespace", metadata: { name: "test" } }; } }]);
1700 "#;
1701 let yaml = render(ts, "test.ts", &test_options()).unwrap();
1702 assert!(yaml.contains("apiVersion: v1"));
1703 assert!(yaml.contains("kind: Namespace"));
1704 assert!(yaml.contains("name: test"));
1705 }
1706
1707 #[test]
1708 fn compile_error_propagates() {
1709 let ts = "const = ;";
1710 let err = render(ts, "bad.ts", &test_options()).unwrap_err();
1711 assert!(matches!(err, HusakoError::Compile(_)));
1712 }
1713
1714 #[test]
1715 fn missing_build_propagates() {
1716 let ts = r#"import { build } from "husako"; const x = 1;"#;
1717 let err = render(ts, "test.ts", &test_options()).unwrap_err();
1718 assert!(matches!(
1719 err,
1720 HusakoError::Runtime(husako_runtime_qjs::RuntimeError::BuildNotCalled)
1721 ));
1722 }
1723
1724 #[test]
1725 fn generate_skip_k8s_writes_static_dts() {
1726 let tmp = tempfile::tempdir().unwrap();
1727 let root = tmp.path().to_path_buf();
1728
1729 let opts = GenerateOptions {
1730 project_root: root.clone(),
1731 openapi: None,
1732 skip_k8s: true,
1733 config: None,
1734 };
1735 generate(&opts, &progress::SilentProgress).unwrap();
1736
1737 assert!(root.join(".husako/types/husako.d.ts").exists());
1739 assert!(root.join(".husako/types/husako/_base.d.ts").exists());
1740
1741 let tsconfig = std::fs::read_to_string(root.join("tsconfig.json")).unwrap();
1743 let parsed: serde_json::Value = serde_json::from_str(&tsconfig).unwrap();
1744 assert!(parsed["compilerOptions"]["paths"]["husako"].is_array());
1745 assert!(parsed["compilerOptions"]["paths"]["k8s/*"].is_array());
1746
1747 assert!(!root.join(".husako/types/k8s").exists());
1749 }
1750
1751 #[test]
1752 fn generate_updates_existing_tsconfig() {
1753 let tmp = tempfile::tempdir().unwrap();
1754 let root = tmp.path().to_path_buf();
1755
1756 let existing = serde_json::json!({
1758 "compilerOptions": {
1759 "strict": true,
1760 "target": "ES2020",
1761 "paths": {
1762 "mylib/*": ["./lib/*"]
1763 }
1764 },
1765 "include": ["src/**/*"]
1766 });
1767 std::fs::write(
1768 root.join("tsconfig.json"),
1769 serde_json::to_string_pretty(&existing).unwrap(),
1770 )
1771 .unwrap();
1772
1773 let opts = GenerateOptions {
1774 project_root: root.clone(),
1775 openapi: None,
1776 skip_k8s: true,
1777 config: None,
1778 };
1779 generate(&opts, &progress::SilentProgress).unwrap();
1780
1781 let tsconfig = std::fs::read_to_string(root.join("tsconfig.json")).unwrap();
1782 let parsed: serde_json::Value = serde_json::from_str(&tsconfig).unwrap();
1783
1784 assert_eq!(parsed["compilerOptions"]["target"], "ES2020");
1786 assert!(parsed["include"].is_array());
1787
1788 assert!(parsed["compilerOptions"]["paths"]["mylib/*"].is_array());
1790
1791 assert!(parsed["compilerOptions"]["paths"]["husako"].is_array());
1793 assert!(parsed["compilerOptions"]["paths"]["k8s/*"].is_array());
1794 }
1795
1796 #[test]
1797 fn template_name_from_str() {
1798 assert_eq!(
1799 TemplateName::from_str("simple").unwrap(),
1800 TemplateName::Simple
1801 );
1802 assert_eq!(
1803 TemplateName::from_str("project").unwrap(),
1804 TemplateName::Project
1805 );
1806 assert_eq!(
1807 TemplateName::from_str("multi-env").unwrap(),
1808 TemplateName::MultiEnv
1809 );
1810 assert!(TemplateName::from_str("unknown").is_err());
1811 }
1812
1813 #[test]
1814 fn template_name_display() {
1815 assert_eq!(TemplateName::Simple.to_string(), "simple");
1816 assert_eq!(TemplateName::Project.to_string(), "project");
1817 assert_eq!(TemplateName::MultiEnv.to_string(), "multi-env");
1818 }
1819
1820 #[test]
1821 fn scaffold_simple_creates_files() {
1822 let tmp = tempfile::tempdir().unwrap();
1823 let dir = tmp.path().join("my-app");
1824
1825 let opts = ScaffoldOptions {
1826 directory: dir.clone(),
1827 template: TemplateName::Simple,
1828 k8s_version: "1.35".to_string(),
1829 };
1830 scaffold(&opts).unwrap();
1831
1832 assert!(dir.join(".gitignore").exists());
1833 assert!(dir.join("husako.toml").exists());
1834 assert!(dir.join("entry.ts").exists());
1835 }
1836
1837 #[test]
1838 fn scaffold_replaces_k8s_version_placeholder() {
1839 let tmp = tempfile::tempdir().unwrap();
1840 let dir = tmp.path().join("my-app");
1841
1842 let opts = ScaffoldOptions {
1843 directory: dir.clone(),
1844 template: TemplateName::Simple,
1845 k8s_version: "1.32".to_string(),
1846 };
1847 scaffold(&opts).unwrap();
1848
1849 let config = std::fs::read_to_string(dir.join("husako.toml")).unwrap();
1850 assert!(config.contains("version = \"1.32\""));
1851 assert!(!config.contains("%K8S_VERSION%"));
1852 }
1853
1854 #[test]
1855 fn init_replaces_k8s_version_placeholder() {
1856 let tmp = tempfile::tempdir().unwrap();
1857
1858 let opts = InitOptions {
1859 directory: tmp.path().to_path_buf(),
1860 template: TemplateName::Simple,
1861 k8s_version: "1.33".to_string(),
1862 };
1863 init(&opts).unwrap();
1864
1865 let config = std::fs::read_to_string(tmp.path().join("husako.toml")).unwrap();
1866 assert!(config.contains("version = \"1.33\""));
1867 assert!(!config.contains("%K8S_VERSION%"));
1868 }
1869
1870 #[test]
1871 fn scaffold_project_creates_files() {
1872 let tmp = tempfile::tempdir().unwrap();
1873 let dir = tmp.path().join("my-app");
1874
1875 let opts = ScaffoldOptions {
1876 directory: dir.clone(),
1877 template: TemplateName::Project,
1878 k8s_version: "1.35".to_string(),
1879 };
1880 scaffold(&opts).unwrap();
1881
1882 assert!(dir.join(".gitignore").exists());
1883 assert!(dir.join("husako.toml").exists());
1884 assert!(dir.join("env/dev.ts").exists());
1885 assert!(dir.join("deployments/nginx.ts").exists());
1886 assert!(dir.join("lib/index.ts").exists());
1887 assert!(dir.join("lib/metadata.ts").exists());
1888 }
1889
1890 #[test]
1891 fn scaffold_multi_env_creates_files() {
1892 let tmp = tempfile::tempdir().unwrap();
1893 let dir = tmp.path().join("my-app");
1894
1895 let opts = ScaffoldOptions {
1896 directory: dir.clone(),
1897 template: TemplateName::MultiEnv,
1898 k8s_version: "1.35".to_string(),
1899 };
1900 scaffold(&opts).unwrap();
1901
1902 assert!(dir.join(".gitignore").exists());
1903 assert!(dir.join("husako.toml").exists());
1904 assert!(dir.join("base/nginx.ts").exists());
1905 assert!(dir.join("base/service.ts").exists());
1906 assert!(dir.join("dev/main.ts").exists());
1907 assert!(dir.join("staging/main.ts").exists());
1908 assert!(dir.join("release/main.ts").exists());
1909 }
1910
1911 #[test]
1912 fn scaffold_rejects_nonempty_dir() {
1913 let tmp = tempfile::tempdir().unwrap();
1914 let dir = tmp.path().join("my-app");
1915 std::fs::create_dir_all(&dir).unwrap();
1916 std::fs::write(dir.join("existing.txt"), "content").unwrap();
1917
1918 let opts = ScaffoldOptions {
1919 directory: dir,
1920 template: TemplateName::Simple,
1921 k8s_version: "1.35".to_string(),
1922 };
1923 let err = scaffold(&opts).unwrap_err();
1924 assert!(matches!(err, HusakoError::GenerateIo(_)));
1925 assert!(err.to_string().contains("not empty"));
1926 }
1927
1928 #[test]
1929 fn scaffold_allows_empty_existing_dir() {
1930 let tmp = tempfile::tempdir().unwrap();
1931 let dir = tmp.path().join("my-app");
1932 std::fs::create_dir_all(&dir).unwrap();
1933
1934 let opts = ScaffoldOptions {
1935 directory: dir.clone(),
1936 template: TemplateName::Simple,
1937 k8s_version: "1.35".to_string(),
1938 };
1939 scaffold(&opts).unwrap();
1940
1941 assert!(dir.join("entry.ts").exists());
1942 }
1943
1944 #[test]
1945 fn generate_chart_types_from_file_source() {
1946 let tmp = tempfile::tempdir().unwrap();
1947 let root = tmp.path().to_path_buf();
1948
1949 std::fs::write(
1951 root.join("values.schema.json"),
1952 r#"{
1953 "type": "object",
1954 "properties": {
1955 "replicaCount": { "type": "integer" },
1956 "image": {
1957 "type": "object",
1958 "properties": {
1959 "repository": { "type": "string" },
1960 "tag": { "type": "string" }
1961 }
1962 }
1963 }
1964 }"#,
1965 )
1966 .unwrap();
1967
1968 let config = husako_config::HusakoConfig {
1969 charts: std::collections::HashMap::from([(
1970 "my-chart".to_string(),
1971 husako_config::ChartSource::File {
1972 path: "values.schema.json".to_string(),
1973 },
1974 )]),
1975 ..Default::default()
1976 };
1977
1978 let opts = GenerateOptions {
1979 project_root: root.clone(),
1980 openapi: None,
1981 skip_k8s: true,
1982 config: Some(config),
1983 };
1984 generate(&opts, &progress::SilentProgress).unwrap();
1985
1986 assert!(root.join(".husako/types/helm/my-chart.d.ts").exists());
1988 assert!(root.join(".husako/types/helm/my-chart.js").exists());
1989
1990 let dts = std::fs::read_to_string(root.join(".husako/types/helm/my-chart.d.ts")).unwrap();
1992 assert!(dts.contains("export interface ValuesSpec"));
1993 assert!(dts.contains("replicaCount"));
1994 assert!(dts.contains("export interface Values extends _SchemaBuilder"));
1995 assert!(dts.contains("export function Values(): Values;"));
1996
1997 let js = std::fs::read_to_string(root.join(".husako/types/helm/my-chart.js")).unwrap();
1999 assert!(js.contains("class _Values extends _SchemaBuilder"));
2000 assert!(js.contains("export function Values()"));
2001
2002 let tsconfig = std::fs::read_to_string(root.join("tsconfig.json")).unwrap();
2004 let parsed: serde_json::Value = serde_json::from_str(&tsconfig).unwrap();
2005 assert!(parsed["compilerOptions"]["paths"]["helm/*"].is_array());
2006 }
2007
2008 #[test]
2009 fn generate_without_charts_no_helm_path() {
2010 let tmp = tempfile::tempdir().unwrap();
2011 let root = tmp.path().to_path_buf();
2012
2013 let opts = GenerateOptions {
2014 project_root: root.clone(),
2015 openapi: None,
2016 skip_k8s: true,
2017 config: None,
2018 };
2019 generate(&opts, &progress::SilentProgress).unwrap();
2020
2021 let tsconfig = std::fs::read_to_string(root.join("tsconfig.json")).unwrap();
2023 let parsed: serde_json::Value = serde_json::from_str(&tsconfig).unwrap();
2024 assert!(parsed["compilerOptions"]["paths"]["helm/*"].is_null());
2025 }
2026
2027 #[test]
2028 fn strip_jsonc_line_comments() {
2029 let input = r#"{
2030 // This is a comment
2031 "key": "value" // inline comment
2032}"#;
2033 let stripped = strip_jsonc(input);
2034 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
2035 assert_eq!(parsed["key"], "value");
2036 }
2037
2038 #[test]
2039 fn strip_jsonc_block_comments() {
2040 let input = r#"{
2041 /* block comment */
2042 "key": "value",
2043 "other": /* inline block */ "data"
2044}"#;
2045 let stripped = strip_jsonc(input);
2046 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
2047 assert_eq!(parsed["key"], "value");
2048 assert_eq!(parsed["other"], "data");
2049 }
2050
2051 #[test]
2052 fn strip_jsonc_trailing_commas() {
2053 let input = r#"{
2054 "a": 1,
2055 "b": [1, 2, 3,],
2056}"#;
2057 let stripped = strip_jsonc(input);
2058 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
2059 assert_eq!(parsed["a"], 1);
2060 assert_eq!(parsed["b"][2], 3);
2061 }
2062
2063 #[test]
2064 fn strip_jsonc_preserves_strings_with_slashes() {
2065 let input = r#"{"url": "https://example.com", "path": "a//b"}"#;
2066 let stripped = strip_jsonc(input);
2067 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
2068 assert_eq!(parsed["url"], "https://example.com");
2069 assert_eq!(parsed["path"], "a//b");
2070 }
2071
2072 #[test]
2073 fn strip_jsonc_tsc_init_style() {
2074 let input = r#"{
2076 "compilerOptions": {
2077 /* Visit https://aka.ms/tsconfig to read more */
2078 "target": "es2016",
2079 // "module": "commonjs",
2080 "strict": true,
2081 "esModuleInterop": true,
2082 "skipLibCheck": true,
2083 "forceConsistentCasingInFileNames": true,
2084 }
2085}"#;
2086 let stripped = strip_jsonc(input);
2087 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
2088 assert_eq!(parsed["compilerOptions"]["target"], "es2016");
2089 assert_eq!(parsed["compilerOptions"]["strict"], true);
2090 assert!(parsed["compilerOptions"]["module"].is_null());
2092 }
2093
2094 #[test]
2095 fn generate_updates_jsonc_tsconfig() {
2096 let tmp = tempfile::tempdir().unwrap();
2097 let root = tmp.path().to_path_buf();
2098
2099 std::fs::write(
2101 root.join("tsconfig.json"),
2102 r#"{
2103 "compilerOptions": {
2104 // TypeScript options
2105 "strict": true,
2106 "target": "ES2022",
2107 }
2108}"#,
2109 )
2110 .unwrap();
2111
2112 let opts = GenerateOptions {
2113 project_root: root.clone(),
2114 openapi: None,
2115 skip_k8s: true,
2116 config: None,
2117 };
2118 generate(&opts, &progress::SilentProgress).unwrap();
2119
2120 let tsconfig = std::fs::read_to_string(root.join("tsconfig.json")).unwrap();
2121 let parsed: serde_json::Value = serde_json::from_str(&tsconfig).unwrap();
2122
2123 assert_eq!(parsed["compilerOptions"]["target"], "ES2022");
2125 assert_eq!(parsed["compilerOptions"]["strict"], true);
2126
2127 assert!(parsed["compilerOptions"]["paths"]["husako"].is_array());
2129 assert!(parsed["compilerOptions"]["paths"]["k8s/*"].is_array());
2130 }
2131
2132 #[test]
2135 fn init_simple_template() {
2136 let tmp = tempfile::tempdir().unwrap();
2137
2138 let opts = InitOptions {
2139 directory: tmp.path().to_path_buf(),
2140 template: TemplateName::Simple,
2141 k8s_version: "1.35".to_string(),
2142 };
2143 init(&opts).unwrap();
2144
2145 assert!(tmp.path().join("husako.toml").exists());
2146 assert!(tmp.path().join("entry.ts").exists());
2147 assert!(tmp.path().join(".gitignore").exists());
2148 }
2149
2150 #[test]
2151 fn init_project_template() {
2152 let tmp = tempfile::tempdir().unwrap();
2153
2154 let opts = InitOptions {
2155 directory: tmp.path().to_path_buf(),
2156 template: TemplateName::Project,
2157 k8s_version: "1.35".to_string(),
2158 };
2159 init(&opts).unwrap();
2160
2161 assert!(tmp.path().join("husako.toml").exists());
2162 assert!(tmp.path().join("env/dev.ts").exists());
2163 }
2164
2165 #[test]
2166 fn init_error_if_config_exists() {
2167 let tmp = tempfile::tempdir().unwrap();
2168 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2169
2170 let opts = InitOptions {
2171 directory: tmp.path().to_path_buf(),
2172 template: TemplateName::Simple,
2173 k8s_version: "1.35".to_string(),
2174 };
2175 let err = init(&opts).unwrap_err();
2176 assert!(err.to_string().contains("already exists"));
2177 }
2178
2179 #[test]
2180 fn init_works_in_nonempty_dir() {
2181 let tmp = tempfile::tempdir().unwrap();
2182 std::fs::write(tmp.path().join("existing.txt"), "content").unwrap();
2183
2184 let opts = InitOptions {
2185 directory: tmp.path().to_path_buf(),
2186 template: TemplateName::Simple,
2187 k8s_version: "1.35".to_string(),
2188 };
2189 init(&opts).unwrap();
2190
2191 assert!(tmp.path().join("husako.toml").exists());
2192 assert!(tmp.path().join("existing.txt").exists());
2193 }
2194
2195 #[test]
2196 fn init_appends_gitignore() {
2197 let tmp = tempfile::tempdir().unwrap();
2198 std::fs::write(tmp.path().join(".gitignore"), "node_modules/\n").unwrap();
2199
2200 let opts = InitOptions {
2201 directory: tmp.path().to_path_buf(),
2202 template: TemplateName::Simple,
2203 k8s_version: "1.35".to_string(),
2204 };
2205 init(&opts).unwrap();
2206
2207 let content = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
2208 assert!(content.contains("node_modules/"));
2209 assert!(content.contains(".husako/"));
2210 }
2211
2212 #[test]
2213 fn init_skips_gitignore_if_husako_present() {
2214 let tmp = tempfile::tempdir().unwrap();
2215 std::fs::write(tmp.path().join(".gitignore"), ".husako/\n").unwrap();
2216
2217 let opts = InitOptions {
2218 directory: tmp.path().to_path_buf(),
2219 template: TemplateName::Simple,
2220 k8s_version: "1.35".to_string(),
2221 };
2222 init(&opts).unwrap();
2223
2224 let content = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
2225 assert_eq!(content.matches(".husako/").count(), 1);
2227 }
2228
2229 #[test]
2230 fn clean_cache_only() {
2231 let tmp = tempfile::tempdir().unwrap();
2232 let root = tmp.path();
2233 std::fs::create_dir_all(root.join(".husako/cache")).unwrap();
2234 std::fs::write(root.join(".husako/cache/test.json"), "data").unwrap();
2235 std::fs::create_dir_all(root.join(".husako/types")).unwrap();
2236 std::fs::write(root.join(".husako/types/test.d.ts"), "types").unwrap();
2237
2238 let opts = CleanOptions {
2239 project_root: root.to_path_buf(),
2240 cache: true,
2241 types: false,
2242 };
2243 let result = clean(&opts).unwrap();
2244 assert!(result.cache_removed);
2245 assert!(!result.types_removed);
2246 assert!(!root.join(".husako/cache").exists());
2247 assert!(root.join(".husako/types").exists());
2248 }
2249
2250 #[test]
2251 fn clean_types_only() {
2252 let tmp = tempfile::tempdir().unwrap();
2253 let root = tmp.path();
2254 std::fs::create_dir_all(root.join(".husako/cache")).unwrap();
2255 std::fs::create_dir_all(root.join(".husako/types")).unwrap();
2256 std::fs::write(root.join(".husako/types/test.d.ts"), "types").unwrap();
2257
2258 let opts = CleanOptions {
2259 project_root: root.to_path_buf(),
2260 cache: false,
2261 types: true,
2262 };
2263 let result = clean(&opts).unwrap();
2264 assert!(!result.cache_removed);
2265 assert!(result.types_removed);
2266 assert!(root.join(".husako/cache").exists());
2267 assert!(!root.join(".husako/types").exists());
2268 }
2269
2270 #[test]
2271 fn clean_both() {
2272 let tmp = tempfile::tempdir().unwrap();
2273 let root = tmp.path();
2274 std::fs::create_dir_all(root.join(".husako/cache")).unwrap();
2275 std::fs::create_dir_all(root.join(".husako/types")).unwrap();
2276
2277 let opts = CleanOptions {
2278 project_root: root.to_path_buf(),
2279 cache: true,
2280 types: true,
2281 };
2282 let result = clean(&opts).unwrap();
2283 assert!(result.cache_removed);
2284 assert!(result.types_removed);
2285 }
2286
2287 #[test]
2288 fn clean_nothing_exists() {
2289 let tmp = tempfile::tempdir().unwrap();
2290
2291 let opts = CleanOptions {
2292 project_root: tmp.path().to_path_buf(),
2293 cache: true,
2294 types: true,
2295 };
2296 let result = clean(&opts).unwrap();
2297 assert!(!result.cache_removed);
2298 assert!(!result.types_removed);
2299 }
2300
2301 #[test]
2302 fn list_empty_config() {
2303 let tmp = tempfile::tempdir().unwrap();
2304 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2305
2306 let deps = list_dependencies(tmp.path()).unwrap();
2307 assert!(deps.resources.is_empty());
2308 assert!(deps.charts.is_empty());
2309 }
2310
2311 #[test]
2312 fn list_resources_only() {
2313 let tmp = tempfile::tempdir().unwrap();
2314 std::fs::write(
2315 tmp.path().join("husako.toml"),
2316 "[resources]\nkubernetes = { source = \"release\", version = \"1.35\" }\n",
2317 )
2318 .unwrap();
2319
2320 let deps = list_dependencies(tmp.path()).unwrap();
2321 assert_eq!(deps.resources.len(), 1);
2322 assert_eq!(deps.resources[0].name, "kubernetes");
2323 assert_eq!(deps.resources[0].source_type, "release");
2324 assert_eq!(deps.resources[0].version.as_deref(), Some("1.35"));
2325 assert!(deps.charts.is_empty());
2326 }
2327
2328 #[test]
2329 fn list_charts_only() {
2330 let tmp = tempfile::tempdir().unwrap();
2331 std::fs::write(
2332 tmp.path().join("husako.toml"),
2333 "[charts]\nmy-chart = { source = \"file\", path = \"./values.schema.json\" }\n",
2334 )
2335 .unwrap();
2336
2337 let deps = list_dependencies(tmp.path()).unwrap();
2338 assert!(deps.resources.is_empty());
2339 assert_eq!(deps.charts.len(), 1);
2340 assert_eq!(deps.charts[0].name, "my-chart");
2341 }
2342
2343 #[test]
2344 fn list_mixed() {
2345 let tmp = tempfile::tempdir().unwrap();
2346 std::fs::write(
2347 tmp.path().join("husako.toml"),
2348 "[resources]\nkubernetes = { source = \"release\", version = \"1.35\" }\n\n[charts]\nmy-chart = { source = \"file\", path = \"./values.schema.json\" }\n",
2349 )
2350 .unwrap();
2351
2352 let deps = list_dependencies(tmp.path()).unwrap();
2353 assert_eq!(deps.resources.len(), 1);
2354 assert_eq!(deps.charts.len(), 1);
2355 }
2356
2357 #[test]
2358 fn list_no_config() {
2359 let tmp = tempfile::tempdir().unwrap();
2360
2361 let deps = list_dependencies(tmp.path()).unwrap();
2362 assert!(deps.resources.is_empty());
2363 assert!(deps.charts.is_empty());
2364 }
2365
2366 #[test]
2369 fn add_resource_creates_entry() {
2370 let tmp = tempfile::tempdir().unwrap();
2371 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2372
2373 let target = AddTarget::Resource {
2374 name: "kubernetes".to_string(),
2375 source: husako_config::SchemaSource::Release {
2376 version: "1.35".to_string(),
2377 },
2378 };
2379 add_dependency(tmp.path(), &target).unwrap();
2380
2381 let content = std::fs::read_to_string(tmp.path().join("husako.toml")).unwrap();
2382 assert!(content.contains("kubernetes"));
2383 assert!(content.contains("release"));
2384 assert!(content.contains("1.35"));
2385 }
2386
2387 #[test]
2388 fn add_chart_creates_entry() {
2389 let tmp = tempfile::tempdir().unwrap();
2390 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2391
2392 let target = AddTarget::Chart {
2393 name: "ingress-nginx".to_string(),
2394 source: husako_config::ChartSource::Registry {
2395 repo: "https://kubernetes.github.io/ingress-nginx".to_string(),
2396 chart: "ingress-nginx".to_string(),
2397 version: "4.12.0".to_string(),
2398 },
2399 };
2400 add_dependency(tmp.path(), &target).unwrap();
2401
2402 let content = std::fs::read_to_string(tmp.path().join("husako.toml")).unwrap();
2403 assert!(content.contains("ingress-nginx"));
2404 assert!(content.contains("4.12.0"));
2405 }
2406
2407 #[test]
2408 fn remove_resource_from_config() {
2409 let tmp = tempfile::tempdir().unwrap();
2410 std::fs::write(
2411 tmp.path().join("husako.toml"),
2412 "[resources]\nkubernetes = { source = \"release\", version = \"1.35\" }\n",
2413 )
2414 .unwrap();
2415
2416 let result = remove_dependency(tmp.path(), "kubernetes").unwrap();
2417 assert_eq!(result.section, "resources");
2418
2419 let content = std::fs::read_to_string(tmp.path().join("husako.toml")).unwrap();
2420 assert!(!content.contains("kubernetes"));
2421 }
2422
2423 #[test]
2424 fn remove_chart_from_config() {
2425 let tmp = tempfile::tempdir().unwrap();
2426 std::fs::write(
2427 tmp.path().join("husako.toml"),
2428 "[charts]\nmy-chart = { source = \"file\", path = \"./values.schema.json\" }\n",
2429 )
2430 .unwrap();
2431
2432 let result = remove_dependency(tmp.path(), "my-chart").unwrap();
2433 assert_eq!(result.section, "charts");
2434 }
2435
2436 #[test]
2437 fn remove_nonexistent_returns_error() {
2438 let tmp = tempfile::tempdir().unwrap();
2439 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2440
2441 let err = remove_dependency(tmp.path(), "nonexistent").unwrap_err();
2442 assert!(err.to_string().contains("not found"));
2443 }
2444
2445 #[test]
2448 fn project_summary_empty() {
2449 let tmp = tempfile::tempdir().unwrap();
2450 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2451
2452 let summary = project_summary(tmp.path()).unwrap();
2453 assert!(summary.config_valid);
2454 assert!(summary.resources.is_empty());
2455 assert!(summary.charts.is_empty());
2456 }
2457
2458 #[test]
2459 fn project_summary_with_deps() {
2460 let tmp = tempfile::tempdir().unwrap();
2461 std::fs::write(
2462 tmp.path().join("husako.toml"),
2463 "[resources]\nkubernetes = { source = \"release\", version = \"1.35\" }\n",
2464 )
2465 .unwrap();
2466
2467 let summary = project_summary(tmp.path()).unwrap();
2468 assert_eq!(summary.resources.len(), 1);
2469 }
2470
2471 #[test]
2472 fn debug_missing_config() {
2473 let tmp = tempfile::tempdir().unwrap();
2474
2475 let report = debug_project(tmp.path()).unwrap();
2476 assert!(report.config_ok.is_none());
2477 assert!(!report.types_exist);
2478 assert!(!report.suggestions.is_empty());
2479 }
2480
2481 #[test]
2482 fn debug_valid_project() {
2483 let tmp = tempfile::tempdir().unwrap();
2484 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2485 std::fs::create_dir_all(tmp.path().join(".husako/types")).unwrap();
2486 std::fs::write(tmp.path().join(".husako/types/husako.d.ts"), "").unwrap();
2487
2488 let opts = GenerateOptions {
2489 project_root: tmp.path().to_path_buf(),
2490 openapi: None,
2491 skip_k8s: true,
2492 config: None,
2493 };
2494 generate(&opts, &progress::SilentProgress).unwrap();
2495
2496 let report = debug_project(tmp.path()).unwrap();
2497 assert_eq!(report.config_ok, Some(true));
2498 assert!(report.types_exist);
2499 assert!(report.tsconfig_ok);
2500 }
2501
2502 #[test]
2503 fn debug_missing_types() {
2504 let tmp = tempfile::tempdir().unwrap();
2505 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2506
2507 let report = debug_project(tmp.path()).unwrap();
2508 assert_eq!(report.config_ok, Some(true));
2509 assert!(!report.types_exist);
2510 }
2511
2512 #[test]
2513 fn validate_valid_ts() {
2514 let ts = r#"
2515 import { build } from "husako";
2516 build([{ _render() { return { apiVersion: "v1", kind: "Namespace", metadata: { name: "test" } }; } }]);
2517 "#;
2518 let options = test_options();
2519 let result = validate_file(ts, "test.ts", &options).unwrap();
2520 assert_eq!(result.resource_count, 1);
2521 assert!(result.validation_errors.is_empty());
2522 }
2523
2524 #[test]
2525 fn validate_compile_error() {
2526 let ts = "const = ;";
2527 let options = test_options();
2528 let err = validate_file(ts, "bad.ts", &options).unwrap_err();
2529 assert!(matches!(err, HusakoError::Compile(_)));
2530 }
2531
2532 #[test]
2533 fn validate_runtime_error() {
2534 let ts = r#"import { build } from "husako"; const x = 1;"#;
2535 let err = validate_file(ts, "test.ts", &test_options()).unwrap_err();
2536 assert!(matches!(err, HusakoError::Runtime(_)));
2537 }
2538
2539 #[test]
2540 fn dependency_detail_not_found() {
2541 let tmp = tempfile::tempdir().unwrap();
2542 std::fs::write(tmp.path().join("husako.toml"), "").unwrap();
2543
2544 let err = dependency_detail(tmp.path(), "nonexistent").unwrap_err();
2545 assert!(err.to_string().contains("not found"));
2546 }
2547
2548 #[test]
2549 fn dependency_detail_resource() {
2550 let tmp = tempfile::tempdir().unwrap();
2551 std::fs::write(
2552 tmp.path().join("husako.toml"),
2553 "[resources]\nkubernetes = { source = \"release\", version = \"1.35\" }\n",
2554 )
2555 .unwrap();
2556
2557 let detail = dependency_detail(tmp.path(), "kubernetes").unwrap();
2558 assert_eq!(detail.info.name, "kubernetes");
2559 assert_eq!(detail.info.source_type, "release");
2560 assert_eq!(detail.info.version.as_deref(), Some("1.35"));
2561 }
2562
2563 #[test]
2564 fn dependency_detail_chart() {
2565 let tmp = tempfile::tempdir().unwrap();
2566 std::fs::write(
2567 tmp.path().join("husako.toml"),
2568 "[charts]\nmy-chart = { source = \"file\", path = \"./values.schema.json\" }\n",
2569 )
2570 .unwrap();
2571
2572 let detail = dependency_detail(tmp.path(), "my-chart").unwrap();
2573 assert_eq!(detail.info.name, "my-chart");
2574 assert_eq!(detail.info.source_type, "file");
2575 }
2576}