Skip to main content

husako_core/
lib.rs

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
146/// Load a `SchemaStore` from `.husako/types/k8s/_schema.json` if it exists.
147pub 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    /// CLI override for OpenAPI source (legacy mode).
154    pub openapi: Option<husako_openapi::FetchOptions>,
155    pub skip_k8s: bool,
156    /// Config from `husako.toml` (config-driven mode).
157    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    // 1. Process plugins: install and collect presets
167    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    // Clone config and merge plugin presets (resources + charts)
176    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    // 2. Write static husako.d.ts
182    write_file(&types_dir.join("husako.d.ts"), husako_sdk::HUSAKO_DTS)?;
183
184    // 3. Write static husako/_base.d.ts
185    write_file(
186        &types_dir.join("husako/_base.d.ts"),
187        husako_sdk::HUSAKO_BASE_DTS,
188    )?;
189
190    // 4. Generate k8s types
191    // Priority: --skip-k8s → CLI flags → husako.toml [schemas] → skip
192    if !options.skip_k8s {
193        let specs = if let Some(openapi_opts) = &options.openapi {
194            // Legacy CLI mode
195            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            // Config-driven mode (includes merged plugin presets)
219            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    // 5. Generate chart (helm) types from [charts] config (includes merged plugin charts)
243    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    // 6. Write/update tsconfig.json (includes plugin module paths)
260    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    // Add helm/* path if charts are configured
290    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    // Add plugin module paths
300    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                // Merge paths into existing compilerOptions
318                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// --- husako new ---
361
362#[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    // Reject non-empty existing directories
404    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    // Create directory
418    std::fs::create_dir_all(dir)
419        .map_err(|e| HusakoError::GenerateIo(format!("create dir {}: {e}", dir.display())))?;
420
421    // Write .gitignore (shared across all templates)
422    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// --- husako init ---
482
483#[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    // Error if husako.toml already exists
494    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    // Write .gitignore: skip if exists, append .husako/ line if missing
502    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// --- husako clean ---
581
582#[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// --- husako list ---
647
648#[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    // List installed plugins
693    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// --- husako add / remove (M17) ---
775
776#[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// --- husako outdated (M18) ---
835
836#[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            // file/cluster have no version concept
927            _ => {}
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            // file has no version concept
1025            _ => {}
1026        }
1027    }
1028
1029    Ok(entries)
1030}
1031
1032// --- husako update (M19) ---
1033
1034#[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    // Filter entries
1071    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        // Load TOML document lazily
1113        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    // Save if we modified the document
1135    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    // Auto-regenerate types if we updated anything
1142    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// --- husako info (M20) ---
1161
1162#[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    // Check resources first
1228    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        // Try to read group-versions from generated types
1234        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    // Check charts
1249    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    // Count properties in ValuesSpec interface
1372    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// --- husako debug (M20) ---
1388
1389#[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    // 1. Check config
1412    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    // 2. Check types directory
1427    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    // 3. Check tsconfig.json
1437    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    // 4. Staleness check
1462    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    // 5. Cache size
1479    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// --- husako validate (M20) ---
1499
1500#[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
1560/// Strip JSONC features (comments and trailing commas) to produce valid JSON.
1561///
1562/// tsconfig.json supports JSONC format: `//` line comments, `/* */` block comments,
1563/// and trailing commas before `}` or `]`. This function strips those so `serde_json`
1564/// can parse the result.
1565fn 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        // Inside a string literal — copy verbatim (handles escaped quotes)
1573        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        // Line comment
1594        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        // Block comment
1603        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; // skip */
1610            }
1611            continue;
1612        }
1613
1614        // Trailing comma: comma followed (ignoring whitespace) by } or ]
1615        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                // Skip the comma, keep whitespace for formatting
1622                i += 1;
1623                continue;
1624            }
1625        }
1626
1627        out.push(chars[i]);
1628        i += 1;
1629    }
1630
1631    out
1632}
1633
1634/// Load plugin module mappings from installed plugins under `.husako/plugins/`.
1635///
1636/// Scans each plugin directory for a `plugin.toml` manifest and builds a
1637/// HashMap of import specifier → absolute `.js` path for the PluginResolver.
1638fn 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        // Check static .d.ts files exist
1738        assert!(root.join(".husako/types/husako.d.ts").exists());
1739        assert!(root.join(".husako/types/husako/_base.d.ts").exists());
1740
1741        // Check tsconfig.json
1742        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        // No k8s/ directory
1748        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        // Pre-create tsconfig.json with existing content
1757        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        // Original fields preserved
1785        assert_eq!(parsed["compilerOptions"]["target"], "ES2020");
1786        assert!(parsed["include"].is_array());
1787
1788        // Original path preserved
1789        assert!(parsed["compilerOptions"]["paths"]["mylib/*"].is_array());
1790
1791        // husako paths added
1792        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        // Create a values.schema.json
1950        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        // Check chart type files exist
1987        assert!(root.join(".husako/types/helm/my-chart.d.ts").exists());
1988        assert!(root.join(".husako/types/helm/my-chart.js").exists());
1989
1990        // Check DTS content
1991        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        // Check JS content
1998        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        // Check tsconfig includes helm path
2003        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        // No helm path in tsconfig when no charts
2022        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        // Simulates the style of tsconfig.json produced by `tsc --init`
2075        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        // Commented-out module should not appear
2091        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        // Pre-create tsconfig.json with JSONC features (comments + trailing comma)
2100        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        // Original fields preserved
2124        assert_eq!(parsed["compilerOptions"]["target"], "ES2022");
2125        assert_eq!(parsed["compilerOptions"]["strict"], true);
2126
2127        // husako paths added
2128        assert!(parsed["compilerOptions"]["paths"]["husako"].is_array());
2129        assert!(parsed["compilerOptions"]["paths"]["k8s/*"].is_array());
2130    }
2131
2132    // --- M16 tests: init, clean, list ---
2133
2134    #[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        // Should not have duplicate .husako/ lines
2226        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    // --- M17 tests: add, remove ---
2367
2368    #[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    // --- M20 tests: info, debug, validate ---
2446
2447    #[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}