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
21pub 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 fn uninstall(&self, project_root: &Path) -> Result<()> {
30 let _ = project_root;
31 anyhow::bail!("Uninstall not yet supported for '{}'", self.name())
32 }
33
34 fn dependencies(&self) -> Vec<&str> {
36 vec![]
37 }
38}
39
40fn resolve_and_install_dependency(name: &str, project_root: &Path) -> Result<()> {
43 use colored::Colorize;
44
45 match name {
46 "auth" => {
47 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
75pub 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 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 crate::ai_context::regenerate(project_root)?;
99
100 Ok(())
101}
102
103pub 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 crate::ai_context::regenerate(project_root).ok();
114
115 Ok(())
116}
117
118pub 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
130pub 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
138pub 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 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
163pub 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 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 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
189pub 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
214pub fn append_env_var(path: &Path, line: &str) -> Result<()> {
216 crate::generator::auth::append_env_var(path, line)
217}
218
219pub 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
229pub 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 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
250pub 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
256pub 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 let line_false = format!("{} = false", feature);
263 remove_line_from_file(&config_path, &line_false)
264}
265
266pub 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(§ion_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 #[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 #[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 #[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 #[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 #[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 #[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}