Skip to main content

romance_core/addon/
mod.rs

1pub mod api_keys;
2pub mod audit_log;
3pub mod cache;
4pub mod dashboard;
5pub mod email;
6pub mod i18n;
7pub mod multitenancy;
8pub mod oauth;
9pub mod observability;
10pub mod search;
11pub mod security;
12pub mod soft_delete;
13pub mod storage;
14pub mod tasks;
15pub mod validation;
16pub mod websocket;
17
18use anyhow::Result;
19use std::path::Path;
20
21/// Trait that all addons implement to provide a consistent installation interface.
22pub trait Addon {
23    fn name(&self) -> &str;
24    fn check_prerequisites(&self, project_root: &Path) -> Result<()>;
25    fn is_already_installed(&self, project_root: &Path) -> bool;
26    fn install(&self, project_root: &Path) -> Result<()>;
27
28    /// Uninstall the addon. Default implementation returns an error.
29    fn uninstall(&self, project_root: &Path) -> Result<()> {
30        let _ = project_root;
31        anyhow::bail!("Uninstall not yet supported for '{}'", self.name())
32    }
33
34    /// Return the names of addons this addon depends on.
35    fn dependencies(&self) -> Vec<&str> {
36        vec![]
37    }
38}
39
40/// Resolve an addon name to its concrete instance and run it.
41/// Used for auto-installing dependencies.
42fn resolve_and_install_dependency(name: &str, project_root: &Path) -> Result<()> {
43    use colored::Colorize;
44
45    match name {
46        "auth" => {
47            // Auth is not an addon, it's a generator. Just check it exists.
48            if !project_root.join("backend/src/auth.rs").exists() {
49                anyhow::bail!(
50                    "Addon requires auth. Run {} first.",
51                    "romance generate auth".bold()
52                );
53            }
54            Ok(())
55        }
56        "validation" => run_addon(&validation::ValidationAddon, project_root),
57        "soft-delete" => run_addon(&soft_delete::SoftDeleteAddon, project_root),
58        "security" => run_addon(&security::SecurityAddon, project_root),
59        "observability" => run_addon(&observability::ObservabilityAddon, project_root),
60        "storage" => run_addon(&storage::StorageAddon, project_root),
61        "search" => run_addon(&search::SearchAddon, project_root),
62        "cache" => run_addon(&cache::CacheAddon, project_root),
63        "email" => run_addon(&email::EmailAddon, project_root),
64        "tasks" => run_addon(&tasks::TasksAddon, project_root),
65        "websocket" => run_addon(&websocket::WebsocketAddon, project_root),
66        "i18n" => run_addon(&i18n::I18nAddon, project_root),
67        "dashboard" => run_addon(&dashboard::DashboardAddon, project_root),
68        "audit-log" => run_addon(&audit_log::AuditLogAddon, project_root),
69        "api-keys" => run_addon(&api_keys::ApiKeysAddon, project_root),
70        "multitenancy" => run_addon(&multitenancy::MultitenancyAddon, project_root),
71        _ => anyhow::bail!("Unknown addon dependency: '{}'", name),
72    }
73}
74
75/// Run an addon: check prerequisites, skip if already installed, then install.
76pub fn run_addon(addon: &dyn Addon, project_root: &Path) -> Result<()> {
77    addon.check_prerequisites(project_root)?;
78
79    if addon.is_already_installed(project_root) {
80        println!("'{}' is already installed, skipping.", addon.name());
81        return Ok(());
82    }
83
84    // Auto-install dependencies
85    let deps = addon.dependencies();
86    if !deps.is_empty() {
87        use colored::Colorize;
88        for dep in &deps {
89            println!("{}", format!("Checking dependency: {}...", dep).dimmed());
90            resolve_and_install_dependency(dep, project_root)?;
91        }
92        println!();
93    }
94
95    addon.install(project_root)?;
96
97    // Regenerate AI context
98    crate::ai_context::regenerate(project_root)?;
99
100    Ok(())
101}
102
103/// Uninstall an addon: check if installed, then uninstall.
104pub fn run_uninstall(addon: &dyn Addon, project_root: &Path) -> Result<()> {
105    if !addon.is_already_installed(project_root) {
106        println!("'{}' is not installed, nothing to remove.", addon.name());
107        return Ok(());
108    }
109
110    addon.uninstall(project_root)?;
111
112    // Regenerate AI context
113    crate::ai_context::regenerate(project_root).ok();
114
115    Ok(())
116}
117
118// =========================================================================
119// Shared helper functions for addon implementations
120// =========================================================================
121
122/// Check that the project root contains a romance.toml file.
123pub fn check_romance_project(project_root: &Path) -> Result<()> {
124    if !project_root.join("romance.toml").exists() {
125        anyhow::bail!("Not a Romance project (romance.toml not found)");
126    }
127    Ok(())
128}
129
130/// Check that auth has been generated (backend/src/auth.rs exists).
131pub fn check_auth_exists(project_root: &Path) -> Result<()> {
132    if !project_root.join("backend/src/auth.rs").exists() {
133        anyhow::bail!("Auth must be generated first. Run: romance generate auth");
134    }
135    Ok(())
136}
137
138/// Add a `mod <mod_name>;` declaration to `backend/src/main.rs`.
139///
140/// Uses `insert_at_marker()` with the `// === ROMANCE:MAIN_MODS ===` marker
141/// if present, otherwise falls back to `str::replace("mod errors;", ...)`.
142pub fn add_mod_to_main(project_root: &Path, mod_name: &str) -> Result<()> {
143    let main_path = project_root.join("backend/src/main.rs");
144    let main_content = std::fs::read_to_string(&main_path)?;
145    let mod_line = format!("mod {};", mod_name);
146
147    if main_content.contains(&mod_line) {
148        return Ok(());
149    }
150
151    let marker = "// === ROMANCE:MAIN_MODS ===";
152    if main_content.contains(marker) {
153        crate::utils::insert_at_marker(&main_path, marker, &mod_line)?;
154    } else {
155        // Fallback for projects scaffolded before the marker existed
156        let new_content = main_content.replace("mod errors;", &format!("mod errors;\n{}", mod_line));
157        std::fs::write(&main_path, new_content)?;
158    }
159
160    Ok(())
161}
162
163/// Add a dependency line to `backend/Cargo.toml`.
164///
165/// Uses `insert_at_marker()` with the `# === ROMANCE:DEPENDENCIES ===` marker
166/// if present, otherwise falls back to appending at end of file.
167pub fn add_cargo_dependency(project_root: &Path, dep_line: &str) -> Result<()> {
168    let cargo_path = project_root.join("backend/Cargo.toml");
169    let content = std::fs::read_to_string(&cargo_path)?;
170
171    // Extract dependency name (everything before ' =')
172    let dep_name = dep_line.split('=').next().unwrap_or("").trim();
173    if content.contains(&format!("{} =", dep_name)) {
174        return Ok(());
175    }
176
177    let marker = "# === ROMANCE:DEPENDENCIES ===";
178    if content.contains(marker) {
179        crate::utils::insert_at_marker(&cargo_path, marker, dep_line)?;
180    } else {
181        // Fallback: append to end of file
182        let new_content = format!("{}\n{}\n", content.trim_end(), dep_line);
183        std::fs::write(&cargo_path, new_content)?;
184    }
185
186    Ok(())
187}
188
189/// Update `romance.toml` to set a feature flag under the `[features]` section.
190///
191/// If the `[features]` section doesn't exist, it creates one.
192pub fn update_feature_flag(project_root: &Path, feature: &str, value: bool) -> Result<()> {
193    let config_path = project_root.join("romance.toml");
194    let content = std::fs::read_to_string(&config_path)?;
195    let line = format!("{} = {}", feature, value);
196
197    if content.contains(&line) {
198        return Ok(());
199    }
200
201    if content.contains("[features]") {
202        if !content.contains(feature) {
203            let new_content = content.replace("[features]", &format!("[features]\n{}", line));
204            std::fs::write(&config_path, new_content)?;
205        }
206    } else {
207        let new_content = format!("{}\n[features]\n{}\n", content.trim_end(), line);
208        std::fs::write(&config_path, new_content)?;
209    }
210
211    Ok(())
212}
213
214/// Append an environment variable line to a `.env` file if not already present.
215pub fn append_env_var(path: &Path, line: &str) -> Result<()> {
216    crate::generator::auth::append_env_var(path, line)
217}
218
219/// Remove a file if it exists. Returns true if file was removed.
220pub fn remove_file_if_exists(path: &Path) -> Result<bool> {
221    if path.exists() {
222        std::fs::remove_file(path)?;
223        Ok(true)
224    } else {
225        Ok(false)
226    }
227}
228
229/// Remove a line containing `needle` from a file.
230pub fn remove_line_from_file(path: &Path, needle: &str) -> Result<()> {
231    if !path.exists() {
232        return Ok(());
233    }
234    let content = std::fs::read_to_string(path)?;
235    let new_content: String = content
236        .lines()
237        .filter(|line| !line.contains(needle))
238        .collect::<Vec<_>>()
239        .join("\n");
240    // Preserve trailing newline
241    let new_content = if content.ends_with('\n') {
242        format!("{}\n", new_content)
243    } else {
244        new_content
245    };
246    std::fs::write(path, new_content)?;
247    Ok(())
248}
249
250/// Remove a `mod <name>;` declaration from `backend/src/main.rs`.
251pub fn remove_mod_from_main(project_root: &Path, mod_name: &str) -> Result<()> {
252    let main_path = project_root.join("backend/src/main.rs");
253    remove_line_from_file(&main_path, &format!("mod {};", mod_name))
254}
255
256/// Remove a feature flag from `romance.toml`'s `[features]` section.
257pub fn remove_feature_flag(project_root: &Path, feature: &str) -> Result<()> {
258    let config_path = project_root.join("romance.toml");
259    let line = format!("{} = true", feature);
260    remove_line_from_file(&config_path, &line)?;
261    // Also remove "feature = false" in case
262    let line_false = format!("{} = false", feature);
263    remove_line_from_file(&config_path, &line_false)
264}
265
266/// Remove a TOML section (e.g., `[security]`) and all its contents until the next section.
267pub fn remove_toml_section(project_root: &Path, section_name: &str) -> Result<()> {
268    let config_path = project_root.join("romance.toml");
269    if !config_path.exists() {
270        return Ok(());
271    }
272    let content = std::fs::read_to_string(&config_path)?;
273    let section_header = format!("[{}]", section_name);
274    if !content.contains(&section_header) {
275        return Ok(());
276    }
277    let mut result_lines: Vec<&str> = Vec::new();
278    let mut skipping = false;
279    for line in content.lines() {
280        if line.trim() == section_header {
281            skipping = true;
282            continue;
283        }
284        if skipping && line.trim().starts_with('[') {
285            skipping = false;
286        }
287        if !skipping {
288            result_lines.push(line);
289        }
290    }
291    let new_content = format!("{}\n", result_lines.join("\n").trim_end());
292    std::fs::write(&config_path, new_content)?;
293    Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn write_romance_toml(dir: &std::path::Path) {
301        std::fs::write(
302            dir.join("romance.toml"),
303            "[project]\nname = \"test\"\n[backend]\nport = 3001\ndatabase_url = \"postgres://localhost/test\"",
304        )
305        .unwrap();
306    }
307
308    // =========================================================================
309    // A) Addon name / identity tests
310    // =========================================================================
311
312    #[test]
313    fn security_addon_name() {
314        let addon = security::SecurityAddon;
315        assert_eq!(addon.name(), "security");
316    }
317
318    #[test]
319    fn validation_addon_name() {
320        let addon = validation::ValidationAddon;
321        assert_eq!(addon.name(), "validation");
322    }
323
324    #[test]
325    fn soft_delete_addon_name() {
326        let addon = soft_delete::SoftDeleteAddon;
327        assert_eq!(addon.name(), "soft-delete");
328    }
329
330    #[test]
331    fn observability_addon_name() {
332        let addon = observability::ObservabilityAddon;
333        assert_eq!(addon.name(), "observability");
334    }
335
336    #[test]
337    fn search_addon_name() {
338        let addon = search::SearchAddon;
339        assert_eq!(addon.name(), "search");
340    }
341
342    #[test]
343    fn email_addon_name() {
344        let addon = email::EmailAddon;
345        assert_eq!(addon.name(), "email");
346    }
347
348    #[test]
349    fn cache_addon_name() {
350        let addon = cache::CacheAddon;
351        assert_eq!(addon.name(), "cache");
352    }
353
354    #[test]
355    fn dashboard_addon_name() {
356        let addon = dashboard::DashboardAddon;
357        assert_eq!(addon.name(), "dashboard");
358    }
359
360    #[test]
361    fn storage_addon_name() {
362        let addon = storage::StorageAddon;
363        assert_eq!(addon.name(), "storage");
364    }
365
366    #[test]
367    fn websocket_addon_name() {
368        let addon = websocket::WebsocketAddon;
369        assert_eq!(addon.name(), "websocket");
370    }
371
372    #[test]
373    fn i18n_addon_name() {
374        let addon = i18n::I18nAddon;
375        assert_eq!(addon.name(), "i18n");
376    }
377
378    #[test]
379    fn tasks_addon_name() {
380        let addon = tasks::TasksAddon;
381        assert_eq!(addon.name(), "tasks");
382    }
383
384    #[test]
385    fn api_keys_addon_name() {
386        let addon = api_keys::ApiKeysAddon;
387        assert_eq!(addon.name(), "api-keys");
388    }
389
390    #[test]
391    fn audit_log_addon_name() {
392        let addon = audit_log::AuditLogAddon;
393        assert_eq!(addon.name(), "audit-log");
394    }
395
396    #[test]
397    fn multitenancy_addon_name() {
398        let addon = multitenancy::MultitenancyAddon;
399        assert_eq!(addon.name(), "multitenancy");
400    }
401
402    #[test]
403    fn oauth_addon_name() {
404        let addon = oauth::OauthAddon {
405            provider: "google".to_string(),
406        };
407        assert_eq!(addon.name(), "oauth");
408    }
409
410    // =========================================================================
411    // B) Prerequisites check tests
412    // =========================================================================
413
414    #[test]
415    fn security_prerequisites_fail_without_romance_toml() {
416        let dir = tempfile::tempdir().unwrap();
417        let result = security::SecurityAddon.check_prerequisites(dir.path());
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn security_prerequisites_pass_with_romance_toml() {
423        let dir = tempfile::tempdir().unwrap();
424        write_romance_toml(dir.path());
425        let result = security::SecurityAddon.check_prerequisites(dir.path());
426        assert!(result.is_ok());
427    }
428
429    #[test]
430    fn validation_prerequisites_fail_without_romance_toml() {
431        let dir = tempfile::tempdir().unwrap();
432        let result = validation::ValidationAddon.check_prerequisites(dir.path());
433        assert!(result.is_err());
434    }
435
436    #[test]
437    fn validation_prerequisites_pass_with_romance_toml() {
438        let dir = tempfile::tempdir().unwrap();
439        write_romance_toml(dir.path());
440        let result = validation::ValidationAddon.check_prerequisites(dir.path());
441        assert!(result.is_ok());
442    }
443
444    #[test]
445    fn soft_delete_prerequisites_fail_without_romance_toml() {
446        let dir = tempfile::tempdir().unwrap();
447        let result = soft_delete::SoftDeleteAddon.check_prerequisites(dir.path());
448        assert!(result.is_err());
449    }
450
451    #[test]
452    fn soft_delete_prerequisites_pass_with_romance_toml() {
453        let dir = tempfile::tempdir().unwrap();
454        write_romance_toml(dir.path());
455        let result = soft_delete::SoftDeleteAddon.check_prerequisites(dir.path());
456        assert!(result.is_ok());
457    }
458
459    #[test]
460    fn api_keys_prerequisites_fail_without_romance_toml() {
461        let dir = tempfile::tempdir().unwrap();
462        let result = api_keys::ApiKeysAddon.check_prerequisites(dir.path());
463        assert!(result.is_err());
464    }
465
466    #[test]
467    fn api_keys_prerequisites_fail_without_auth() {
468        let dir = tempfile::tempdir().unwrap();
469        write_romance_toml(dir.path());
470        let result = api_keys::ApiKeysAddon.check_prerequisites(dir.path());
471        assert!(result.is_err());
472        let err_msg = result.unwrap_err().to_string();
473        assert!(err_msg.contains("Auth must be generated first"));
474    }
475
476    #[test]
477    fn api_keys_prerequisites_pass_with_auth() {
478        let dir = tempfile::tempdir().unwrap();
479        write_romance_toml(dir.path());
480        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
481        std::fs::write(dir.path().join("backend/src/auth.rs"), "").unwrap();
482        let result = api_keys::ApiKeysAddon.check_prerequisites(dir.path());
483        assert!(result.is_ok());
484    }
485
486    #[test]
487    fn audit_log_prerequisites_fail_without_auth() {
488        let dir = tempfile::tempdir().unwrap();
489        write_romance_toml(dir.path());
490        let result = audit_log::AuditLogAddon.check_prerequisites(dir.path());
491        assert!(result.is_err());
492        let err_msg = result.unwrap_err().to_string();
493        assert!(err_msg.contains("Auth must be generated first"));
494    }
495
496    #[test]
497    fn audit_log_prerequisites_pass_with_auth() {
498        let dir = tempfile::tempdir().unwrap();
499        write_romance_toml(dir.path());
500        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
501        std::fs::write(dir.path().join("backend/src/auth.rs"), "").unwrap();
502        let result = audit_log::AuditLogAddon.check_prerequisites(dir.path());
503        assert!(result.is_ok());
504    }
505
506    #[test]
507    fn multitenancy_prerequisites_fail_without_auth() {
508        let dir = tempfile::tempdir().unwrap();
509        write_romance_toml(dir.path());
510        let result = multitenancy::MultitenancyAddon.check_prerequisites(dir.path());
511        assert!(result.is_err());
512        let err_msg = result.unwrap_err().to_string();
513        assert!(err_msg.contains("Auth must be generated first"));
514    }
515
516    #[test]
517    fn multitenancy_prerequisites_pass_with_auth() {
518        let dir = tempfile::tempdir().unwrap();
519        write_romance_toml(dir.path());
520        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
521        std::fs::write(dir.path().join("backend/src/auth.rs"), "").unwrap();
522        let result = multitenancy::MultitenancyAddon.check_prerequisites(dir.path());
523        assert!(result.is_ok());
524    }
525
526    #[test]
527    fn oauth_prerequisites_fail_without_auth() {
528        let dir = tempfile::tempdir().unwrap();
529        write_romance_toml(dir.path());
530        let addon = oauth::OauthAddon {
531            provider: "google".to_string(),
532        };
533        let result = addon.check_prerequisites(dir.path());
534        assert!(result.is_err());
535        let err_msg = result.unwrap_err().to_string();
536        assert!(err_msg.contains("Auth must be generated first"));
537    }
538
539    #[test]
540    fn oauth_prerequisites_pass_with_auth() {
541        let dir = tempfile::tempdir().unwrap();
542        write_romance_toml(dir.path());
543        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
544        std::fs::write(dir.path().join("backend/src/auth.rs"), "").unwrap();
545        let addon = oauth::OauthAddon {
546            provider: "github".to_string(),
547        };
548        let result = addon.check_prerequisites(dir.path());
549        assert!(result.is_ok());
550    }
551
552    // =========================================================================
553    // C) is_already_installed tests
554    // =========================================================================
555
556    #[test]
557    fn security_not_installed_in_empty_dir() {
558        let dir = tempfile::tempdir().unwrap();
559        assert!(!security::SecurityAddon.is_already_installed(dir.path()));
560    }
561
562    #[test]
563    fn security_installed_when_marker_exists() {
564        let dir = tempfile::tempdir().unwrap();
565        let middleware_dir = dir.path().join("backend/src/middleware");
566        std::fs::create_dir_all(&middleware_dir).unwrap();
567        std::fs::write(middleware_dir.join("security_headers.rs"), "").unwrap();
568        assert!(security::SecurityAddon.is_already_installed(dir.path()));
569    }
570
571    #[test]
572    fn validation_not_installed_in_empty_dir() {
573        let dir = tempfile::tempdir().unwrap();
574        assert!(!validation::ValidationAddon.is_already_installed(dir.path()));
575    }
576
577    #[test]
578    fn validation_installed_when_marker_exists() {
579        let dir = tempfile::tempdir().unwrap();
580        let backend_src = dir.path().join("backend/src");
581        std::fs::create_dir_all(&backend_src).unwrap();
582        std::fs::write(backend_src.join("validation.rs"), "").unwrap();
583        assert!(validation::ValidationAddon.is_already_installed(dir.path()));
584    }
585
586    #[test]
587    fn soft_delete_not_installed_in_empty_dir() {
588        let dir = tempfile::tempdir().unwrap();
589        assert!(!soft_delete::SoftDeleteAddon.is_already_installed(dir.path()));
590    }
591
592    #[test]
593    fn soft_delete_installed_when_marker_exists() {
594        let dir = tempfile::tempdir().unwrap();
595        let backend_src = dir.path().join("backend/src");
596        std::fs::create_dir_all(&backend_src).unwrap();
597        std::fs::write(backend_src.join("soft_delete.rs"), "").unwrap();
598        assert!(soft_delete::SoftDeleteAddon.is_already_installed(dir.path()));
599    }
600
601    #[test]
602    fn observability_not_installed_in_empty_dir() {
603        let dir = tempfile::tempdir().unwrap();
604        assert!(!observability::ObservabilityAddon.is_already_installed(dir.path()));
605    }
606
607    #[test]
608    fn observability_installed_when_marker_exists() {
609        let dir = tempfile::tempdir().unwrap();
610        let middleware_dir = dir.path().join("backend/src/middleware");
611        std::fs::create_dir_all(&middleware_dir).unwrap();
612        std::fs::write(middleware_dir.join("request_id.rs"), "").unwrap();
613        assert!(observability::ObservabilityAddon.is_already_installed(dir.path()));
614    }
615
616    #[test]
617    fn search_not_installed_in_empty_dir() {
618        let dir = tempfile::tempdir().unwrap();
619        assert!(!search::SearchAddon.is_already_installed(dir.path()));
620    }
621
622    #[test]
623    fn search_installed_when_marker_exists() {
624        let dir = tempfile::tempdir().unwrap();
625        let backend_src = dir.path().join("backend/src");
626        std::fs::create_dir_all(&backend_src).unwrap();
627        std::fs::write(backend_src.join("search.rs"), "").unwrap();
628        assert!(search::SearchAddon.is_already_installed(dir.path()));
629    }
630
631    #[test]
632    fn email_not_installed_in_empty_dir() {
633        let dir = tempfile::tempdir().unwrap();
634        assert!(!email::EmailAddon.is_already_installed(dir.path()));
635    }
636
637    #[test]
638    fn email_installed_when_marker_exists() {
639        let dir = tempfile::tempdir().unwrap();
640        let backend_src = dir.path().join("backend/src");
641        std::fs::create_dir_all(&backend_src).unwrap();
642        std::fs::write(backend_src.join("email.rs"), "").unwrap();
643        assert!(email::EmailAddon.is_already_installed(dir.path()));
644    }
645
646    #[test]
647    fn cache_not_installed_in_empty_dir() {
648        let dir = tempfile::tempdir().unwrap();
649        assert!(!cache::CacheAddon.is_already_installed(dir.path()));
650    }
651
652    #[test]
653    fn cache_installed_when_marker_exists() {
654        let dir = tempfile::tempdir().unwrap();
655        let backend_src = dir.path().join("backend/src");
656        std::fs::create_dir_all(&backend_src).unwrap();
657        std::fs::write(backend_src.join("cache.rs"), "").unwrap();
658        assert!(cache::CacheAddon.is_already_installed(dir.path()));
659    }
660
661    #[test]
662    fn dashboard_not_installed_in_empty_dir() {
663        let dir = tempfile::tempdir().unwrap();
664        assert!(!dashboard::DashboardAddon.is_already_installed(dir.path()));
665    }
666
667    #[test]
668    fn dashboard_installed_when_marker_exists() {
669        let dir = tempfile::tempdir().unwrap();
670        let dev_dir = dir.path().join("frontend/src/features/dev");
671        std::fs::create_dir_all(&dev_dir).unwrap();
672        std::fs::write(dev_dir.join("DevDashboard.tsx"), "").unwrap();
673        assert!(dashboard::DashboardAddon.is_already_installed(dir.path()));
674    }
675
676    #[test]
677    fn storage_not_installed_in_empty_dir() {
678        let dir = tempfile::tempdir().unwrap();
679        assert!(!storage::StorageAddon.is_already_installed(dir.path()));
680    }
681
682    #[test]
683    fn storage_installed_when_marker_exists() {
684        let dir = tempfile::tempdir().unwrap();
685        let backend_src = dir.path().join("backend/src");
686        std::fs::create_dir_all(&backend_src).unwrap();
687        std::fs::write(backend_src.join("storage.rs"), "").unwrap();
688        assert!(storage::StorageAddon.is_already_installed(dir.path()));
689    }
690
691    #[test]
692    fn websocket_not_installed_in_empty_dir() {
693        let dir = tempfile::tempdir().unwrap();
694        assert!(!websocket::WebsocketAddon.is_already_installed(dir.path()));
695    }
696
697    #[test]
698    fn websocket_installed_when_marker_exists() {
699        let dir = tempfile::tempdir().unwrap();
700        let backend_src = dir.path().join("backend/src");
701        std::fs::create_dir_all(&backend_src).unwrap();
702        std::fs::write(backend_src.join("ws.rs"), "").unwrap();
703        assert!(websocket::WebsocketAddon.is_already_installed(dir.path()));
704    }
705
706    #[test]
707    fn i18n_not_installed_in_empty_dir() {
708        let dir = tempfile::tempdir().unwrap();
709        assert!(!i18n::I18nAddon.is_already_installed(dir.path()));
710    }
711
712    #[test]
713    fn i18n_installed_when_marker_exists() {
714        let dir = tempfile::tempdir().unwrap();
715        let backend_src = dir.path().join("backend/src");
716        std::fs::create_dir_all(&backend_src).unwrap();
717        std::fs::write(backend_src.join("i18n.rs"), "").unwrap();
718        assert!(i18n::I18nAddon.is_already_installed(dir.path()));
719    }
720
721    #[test]
722    fn tasks_not_installed_in_empty_dir() {
723        let dir = tempfile::tempdir().unwrap();
724        assert!(!tasks::TasksAddon.is_already_installed(dir.path()));
725    }
726
727    #[test]
728    fn tasks_installed_when_marker_exists() {
729        let dir = tempfile::tempdir().unwrap();
730        let backend_src = dir.path().join("backend/src");
731        std::fs::create_dir_all(&backend_src).unwrap();
732        std::fs::write(backend_src.join("tasks.rs"), "").unwrap();
733        assert!(tasks::TasksAddon.is_already_installed(dir.path()));
734    }
735
736    #[test]
737    fn api_keys_not_installed_in_empty_dir() {
738        let dir = tempfile::tempdir().unwrap();
739        assert!(!api_keys::ApiKeysAddon.is_already_installed(dir.path()));
740    }
741
742    #[test]
743    fn api_keys_installed_when_marker_exists() {
744        let dir = tempfile::tempdir().unwrap();
745        let backend_src = dir.path().join("backend/src");
746        std::fs::create_dir_all(&backend_src).unwrap();
747        std::fs::write(backend_src.join("api_keys.rs"), "").unwrap();
748        assert!(api_keys::ApiKeysAddon.is_already_installed(dir.path()));
749    }
750
751    #[test]
752    fn audit_log_not_installed_in_empty_dir() {
753        let dir = tempfile::tempdir().unwrap();
754        assert!(!audit_log::AuditLogAddon.is_already_installed(dir.path()));
755    }
756
757    #[test]
758    fn audit_log_installed_when_marker_exists() {
759        let dir = tempfile::tempdir().unwrap();
760        let backend_src = dir.path().join("backend/src");
761        std::fs::create_dir_all(&backend_src).unwrap();
762        std::fs::write(backend_src.join("audit.rs"), "").unwrap();
763        assert!(audit_log::AuditLogAddon.is_already_installed(dir.path()));
764    }
765
766    #[test]
767    fn multitenancy_not_installed_in_empty_dir() {
768        let dir = tempfile::tempdir().unwrap();
769        assert!(!multitenancy::MultitenancyAddon.is_already_installed(dir.path()));
770    }
771
772    #[test]
773    fn multitenancy_installed_when_marker_exists() {
774        let dir = tempfile::tempdir().unwrap();
775        let backend_src = dir.path().join("backend/src");
776        std::fs::create_dir_all(&backend_src).unwrap();
777        std::fs::write(backend_src.join("tenant.rs"), "").unwrap();
778        assert!(multitenancy::MultitenancyAddon.is_already_installed(dir.path()));
779    }
780
781    #[test]
782    fn oauth_not_installed_in_empty_dir() {
783        let dir = tempfile::tempdir().unwrap();
784        let addon = oauth::OauthAddon {
785            provider: "google".to_string(),
786        };
787        assert!(!addon.is_already_installed(dir.path()));
788    }
789
790    #[test]
791    fn oauth_installed_when_marker_exists() {
792        let dir = tempfile::tempdir().unwrap();
793        let backend_src = dir.path().join("backend/src");
794        std::fs::create_dir_all(&backend_src).unwrap();
795        std::fs::write(backend_src.join("oauth.rs"), "").unwrap();
796        let addon = oauth::OauthAddon {
797            provider: "google".to_string(),
798        };
799        assert!(addon.is_already_installed(dir.path()));
800    }
801
802    // =========================================================================
803    // D) Uninstall helper tests
804    // =========================================================================
805
806    #[test]
807    fn remove_file_if_exists_returns_true_when_file_exists() {
808        let dir = tempfile::tempdir().unwrap();
809        let path = dir.path().join("test.rs");
810        std::fs::write(&path, "content").unwrap();
811        assert!(remove_file_if_exists(&path).unwrap());
812        assert!(!path.exists());
813    }
814
815    #[test]
816    fn remove_file_if_exists_returns_false_when_missing() {
817        let dir = tempfile::tempdir().unwrap();
818        let path = dir.path().join("nonexistent.rs");
819        assert!(!remove_file_if_exists(&path).unwrap());
820    }
821
822    #[test]
823    fn remove_line_from_file_removes_matching_line() {
824        let dir = tempfile::tempdir().unwrap();
825        let path = dir.path().join("test.rs");
826        std::fs::write(&path, "mod a;\nmod b;\nmod c;\n").unwrap();
827        remove_line_from_file(&path, "mod b;").unwrap();
828        let content = std::fs::read_to_string(&path).unwrap();
829        assert!(!content.contains("mod b;"));
830        assert!(content.contains("mod a;"));
831        assert!(content.contains("mod c;"));
832    }
833
834    #[test]
835    fn remove_line_from_file_noop_when_not_found() {
836        let dir = tempfile::tempdir().unwrap();
837        let path = dir.path().join("test.rs");
838        std::fs::write(&path, "mod a;\nmod c;\n").unwrap();
839        remove_line_from_file(&path, "mod b;").unwrap();
840        let content = std::fs::read_to_string(&path).unwrap();
841        assert!(content.contains("mod a;"));
842        assert!(content.contains("mod c;"));
843    }
844
845    #[test]
846    fn remove_mod_from_main_works() {
847        let dir = tempfile::tempdir().unwrap();
848        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
849        std::fs::write(
850            dir.path().join("backend/src/main.rs"),
851            "mod errors;\nmod validation;\n// === ROMANCE:MAIN_MODS ===\nmod handlers;\n",
852        )
853        .unwrap();
854        remove_mod_from_main(dir.path(), "validation").unwrap();
855        let content =
856            std::fs::read_to_string(dir.path().join("backend/src/main.rs")).unwrap();
857        assert!(!content.contains("mod validation;"));
858        assert!(content.contains("mod errors;"));
859    }
860
861    #[test]
862    fn remove_feature_flag_works() {
863        let dir = tempfile::tempdir().unwrap();
864        std::fs::write(
865            dir.path().join("romance.toml"),
866            "[project]\nname = \"test\"\n[features]\nvalidation = true\ncache = true\n",
867        )
868        .unwrap();
869        remove_feature_flag(dir.path(), "validation").unwrap();
870        let content = std::fs::read_to_string(dir.path().join("romance.toml")).unwrap();
871        assert!(!content.contains("validation"));
872        assert!(content.contains("cache = true"));
873    }
874
875    #[test]
876    fn remove_toml_section_works() {
877        let dir = tempfile::tempdir().unwrap();
878        std::fs::write(
879            dir.path().join("romance.toml"),
880            "[project]\nname = \"test\"\n\n[security]\nrate_limit = 60\ncors = true\n\n[features]\nauth = true\n",
881        )
882        .unwrap();
883        remove_toml_section(dir.path(), "security").unwrap();
884        let content = std::fs::read_to_string(dir.path().join("romance.toml")).unwrap();
885        assert!(!content.contains("[security]"));
886        assert!(!content.contains("rate_limit"));
887        assert!(content.contains("[features]"));
888        assert!(content.contains("[project]"));
889    }
890
891    // =========================================================================
892    // E) Dependencies tests
893    // =========================================================================
894
895    #[test]
896    fn audit_log_depends_on_auth() {
897        let addon = audit_log::AuditLogAddon;
898        assert_eq!(addon.dependencies(), vec!["auth"]);
899    }
900
901    #[test]
902    fn oauth_depends_on_auth() {
903        let addon = oauth::OauthAddon {
904            provider: "google".to_string(),
905        };
906        assert_eq!(addon.dependencies(), vec!["auth"]);
907    }
908
909    #[test]
910    fn api_keys_depends_on_auth() {
911        let addon = api_keys::ApiKeysAddon;
912        assert_eq!(addon.dependencies(), vec!["auth"]);
913    }
914
915    #[test]
916    fn multitenancy_depends_on_auth() {
917        let addon = multitenancy::MultitenancyAddon;
918        assert_eq!(addon.dependencies(), vec!["auth"]);
919    }
920
921    #[test]
922    fn security_has_no_dependencies() {
923        let addon = security::SecurityAddon;
924        assert!(addon.dependencies().is_empty());
925    }
926
927    #[test]
928    fn validation_has_no_dependencies() {
929        let addon = validation::ValidationAddon;
930        assert!(addon.dependencies().is_empty());
931    }
932
933    // =========================================================================
934    // F) Shared helper tests
935    // =========================================================================
936
937    #[test]
938    fn check_romance_project_fails_without_toml() {
939        let dir = tempfile::tempdir().unwrap();
940        assert!(check_romance_project(dir.path()).is_err());
941    }
942
943    #[test]
944    fn check_romance_project_passes_with_toml() {
945        let dir = tempfile::tempdir().unwrap();
946        write_romance_toml(dir.path());
947        assert!(check_romance_project(dir.path()).is_ok());
948    }
949
950    #[test]
951    fn check_auth_exists_fails_without_auth() {
952        let dir = tempfile::tempdir().unwrap();
953        assert!(check_auth_exists(dir.path()).is_err());
954    }
955
956    #[test]
957    fn check_auth_exists_passes_with_auth() {
958        let dir = tempfile::tempdir().unwrap();
959        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
960        std::fs::write(dir.path().join("backend/src/auth.rs"), "").unwrap();
961        assert!(check_auth_exists(dir.path()).is_ok());
962    }
963
964    #[test]
965    fn add_mod_to_main_with_marker() {
966        let dir = tempfile::tempdir().unwrap();
967        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
968        std::fs::write(
969            dir.path().join("backend/src/main.rs"),
970            "mod errors;\n// === ROMANCE:MAIN_MODS ===\nmod handlers;\n",
971        )
972        .unwrap();
973        add_mod_to_main(dir.path(), "storage").unwrap();
974        let content = std::fs::read_to_string(dir.path().join("backend/src/main.rs")).unwrap();
975        assert!(content.contains("mod storage;"));
976        assert!(content.contains("// === ROMANCE:MAIN_MODS ==="));
977    }
978
979    #[test]
980    fn add_mod_to_main_without_marker_fallback() {
981        let dir = tempfile::tempdir().unwrap();
982        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
983        std::fs::write(
984            dir.path().join("backend/src/main.rs"),
985            "mod errors;\nmod handlers;\n",
986        )
987        .unwrap();
988        add_mod_to_main(dir.path(), "storage").unwrap();
989        let content = std::fs::read_to_string(dir.path().join("backend/src/main.rs")).unwrap();
990        assert!(content.contains("mod storage;"));
991    }
992
993    #[test]
994    fn add_mod_to_main_idempotent() {
995        let dir = tempfile::tempdir().unwrap();
996        std::fs::create_dir_all(dir.path().join("backend/src")).unwrap();
997        std::fs::write(
998            dir.path().join("backend/src/main.rs"),
999            "mod errors;\n// === ROMANCE:MAIN_MODS ===\nmod handlers;\n",
1000        )
1001        .unwrap();
1002        add_mod_to_main(dir.path(), "storage").unwrap();
1003        add_mod_to_main(dir.path(), "storage").unwrap();
1004        let content = std::fs::read_to_string(dir.path().join("backend/src/main.rs")).unwrap();
1005        assert_eq!(content.matches("mod storage;").count(), 1);
1006    }
1007
1008    #[test]
1009    fn update_feature_flag_creates_section() {
1010        let dir = tempfile::tempdir().unwrap();
1011        write_romance_toml(dir.path());
1012        update_feature_flag(dir.path(), "cache", true).unwrap();
1013        let content = std::fs::read_to_string(dir.path().join("romance.toml")).unwrap();
1014        assert!(content.contains("[features]"));
1015        assert!(content.contains("cache = true"));
1016    }
1017
1018    #[test]
1019    fn update_feature_flag_appends_to_existing_section() {
1020        let dir = tempfile::tempdir().unwrap();
1021        std::fs::write(
1022            dir.path().join("romance.toml"),
1023            "[project]\nname = \"test\"\n[features]\nauth = true\n",
1024        )
1025        .unwrap();
1026        update_feature_flag(dir.path(), "cache", true).unwrap();
1027        let content = std::fs::read_to_string(dir.path().join("romance.toml")).unwrap();
1028        assert!(content.contains("cache = true"));
1029        assert!(content.contains("auth = true"));
1030    }
1031
1032    #[test]
1033    fn update_feature_flag_idempotent() {
1034        let dir = tempfile::tempdir().unwrap();
1035        write_romance_toml(dir.path());
1036        update_feature_flag(dir.path(), "cache", true).unwrap();
1037        update_feature_flag(dir.path(), "cache", true).unwrap();
1038        let content = std::fs::read_to_string(dir.path().join("romance.toml")).unwrap();
1039        assert_eq!(content.matches("cache = true").count(), 1);
1040    }
1041}