From f61107667448277658cdf6d0ce74bb083e8385b3 Mon Sep 17 00:00:00 2001 From: Edouard Zemb Date: Sun, 8 Feb 2026 21:47:31 +0100 Subject: [PATCH 1/2] style(tf-config,tf-logging): apply cargo fmt formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reformat long lines, multi-field structs, and chained method calls across tf-config (error, lib, template, profile tests) and tf-logging (config, error) to comply with rustfmt defaults. No logic or behavior changes — purely whitespace and line-break adjustments. Co-Authored-By: Claude Opus 4.6 --- crates/tf-config/src/error.rs | 5 +- crates/tf-config/src/lib.rs | 4 +- crates/tf-config/src/template.rs | 97 ++++++++----- crates/tf-config/tests/profile_tests.rs | 137 +++++++++++++++---- crates/tf-config/tests/profile_unit_tests.rs | 27 +++- crates/tf-logging/src/config.rs | 10 +- crates/tf-logging/src/error.rs | 28 ++-- 7 files changed, 221 insertions(+), 87 deletions(-) diff --git a/crates/tf-config/src/error.rs b/crates/tf-config/src/error.rs index e07a0a6..3f43f83 100644 --- a/crates/tf-config/src/error.rs +++ b/crates/tf-config/src/error.rs @@ -48,7 +48,10 @@ pub enum ConfigError { /// that is not defined in the `profiles` section of the configuration. /// The error includes the list of available profiles to help the user /// select a valid one. - #[error("Profile '{requested}' not found. {}", format_available_profiles(available))] + #[error( + "Profile '{requested}' not found. {}", + format_available_profiles(available) + )] ProfileNotFound { /// The profile name that was requested requested: String, diff --git a/crates/tf-config/src/lib.rs b/crates/tf-config/src/lib.rs index a8d1da5..7c1b724 100644 --- a/crates/tf-config/src/lib.rs +++ b/crates/tf-config/src/lib.rs @@ -62,8 +62,8 @@ pub mod profiles; pub mod template; pub use config::{ - load_config, redact_url_sensitive_params, JiraConfig, LlmConfig, LlmMode, ProjectConfig, Redact, - SquashConfig, TemplatesConfig, + load_config, redact_url_sensitive_params, JiraConfig, LlmConfig, LlmMode, ProjectConfig, + Redact, SquashConfig, TemplatesConfig, }; pub use error::ConfigError; diff --git a/crates/tf-config/src/template.rs b/crates/tf-config/src/template.rs index c07e0bc..9f6b24e 100644 --- a/crates/tf-config/src/template.rs +++ b/crates/tf-config/src/template.rs @@ -123,7 +123,9 @@ pub enum TemplateError { }, /// Template file has wrong extension - #[error("Invalid extension for template '{path}': expected {expected}, got '{actual}'. {hint}")] + #[error( + "Invalid extension for template '{path}': expected {expected}, got '{actual}'. {hint}" + )] InvalidExtension { path: String, expected: String, @@ -303,7 +305,11 @@ impl<'a> TemplateLoader<'a> { /// location. Callers running the CLI from a different directory may get /// unexpected `FileNotFound` errors. Use absolute paths in config to avoid /// ambiguity. - fn load_from_path(&self, kind: TemplateKind, path_str: &str) -> Result { + fn load_from_path( + &self, + kind: TemplateKind, + path_str: &str, + ) -> Result { let path = PathBuf::from(path_str); let path_for_error = sanitize_path_for_error(path_str); @@ -409,20 +415,20 @@ impl<'a> TemplateLoader<'a> { /// Get the configured path for a template kind, returning an error if not configured. fn get_configured_path(&self, kind: TemplateKind) -> Result<&str, TemplateError> { - self.resolve_path(kind).ok_or_else(|| TemplateError::NotConfigured { - kind, - hint: format!( - "Add 'templates.{}: ./path/to/{template_file}' to your config.yaml", + self.resolve_path(kind) + .ok_or_else(|| TemplateError::NotConfigured { kind, - template_file = match kind { - TemplateKind::Cr => "cr.md", - TemplateKind::Ppt => "report.pptx", - TemplateKind::Anomaly => "anomaly.md", - } - ), - }) + hint: format!( + "Add 'templates.{}: ./path/to/{template_file}' to your config.yaml", + kind, + template_file = match kind { + TemplateKind::Cr => "cr.md", + TemplateKind::Ppt => "report.pptx", + TemplateKind::Anomaly => "anomaly.md", + } + ), + }) } - } /// Validate the file extension matches the expected format (case-insensitive) @@ -439,7 +445,9 @@ fn validate_extension(path: &Path, kind: TemplateKind) -> Result<(), TemplateErr .unwrap_or(false); if !matches { - let actual = ext_str.map(|e| format!(".{}", e)).unwrap_or_else(|| "(none)".to_string()); + let actual = ext_str + .map(|e| format!(".{}", e)) + .unwrap_or_else(|| "(none)".to_string()); return Err(TemplateError::InvalidExtension { path: sanitize_path_for_error(&path.display().to_string()), expected: expected.to_string(), @@ -453,7 +461,12 @@ fn validate_extension(path: &Path, kind: TemplateKind) -> Result<(), TemplateErr /// Build an `InvalidFormat` error for oversized files, used by both the /// pre-read metadata check and the post-read TOCTOU guard. -fn oversized_error(path: &str, kind: TemplateKind, actual_size: u64, max_size: u64) -> TemplateError { +fn oversized_error( + path: &str, + kind: TemplateKind, + actual_size: u64, + max_size: u64, +) -> TemplateError { TemplateError::InvalidFormat { path: sanitize_path_for_error(path), kind, @@ -478,7 +491,11 @@ fn oversized_error(path: &str, kind: TemplateKind, actual_size: u64, max_size: u /// The `path` parameter is used **only for error context** (included in error /// messages to help the user locate the problematic file). It is not validated, /// resolved, or read from — callers may pass any descriptive path. -pub fn validate_content(kind: TemplateKind, content: &[u8], path: &Path) -> Result<(), TemplateError> { +pub fn validate_content( + kind: TemplateKind, + content: &[u8], + path: &Path, +) -> Result<(), TemplateError> { let path_str = sanitize_path_for_error(&path.display().to_string()); match kind { TemplateKind::Cr | TemplateKind::Anomaly => validate_markdown(content, &path_str, kind), @@ -577,7 +594,8 @@ fn validate_markdown(content: &[u8], path: &str, kind: TemplateKind) -> Result<( path: path.to_string(), kind, cause: "file contains only whitespace".to_string(), - hint: "Ensure the file is a valid Markdown template with meaningful content".to_string(), + hint: "Ensure the file is a valid Markdown template with meaningful content" + .to_string(), }); } @@ -1096,7 +1114,8 @@ mod tests { let mut content = Vec::new(); content.extend_from_slice(b"PK\x03\x04"); content.resize(50, 0x00); // Below MIN_PPTX_SIZE - let err = validate_content(TemplateKind::Ppt, &content, Path::new("small.pptx")).unwrap_err(); + let err = + validate_content(TemplateKind::Ppt, &content, Path::new("small.pptx")).unwrap_err(); assert!(matches!(err, TemplateError::InvalidFormat { .. })); assert!(err.to_string().contains("too small")); } @@ -1107,7 +1126,8 @@ mod tests { let mut content = Vec::new(); content.extend_from_slice(b"PK\x03\x04"); content.resize(MIN_PPTX_SIZE - 1, 0x00); - let err = validate_content(TemplateKind::Ppt, &content, Path::new("boundary.pptx")).unwrap_err(); + let err = + validate_content(TemplateKind::Ppt, &content, Path::new("boundary.pptx")).unwrap_err(); assert!(matches!(err, TemplateError::InvalidFormat { .. })); assert!(err.to_string().contains("too small")); } @@ -1123,8 +1143,12 @@ mod tests { fn test_validate_pptx_missing_content_types_rejected() { let content = create_single_entry_zip("ppt/slides/slide1.xml", &[0x01; 64]); - let err = validate_content(TemplateKind::Ppt, &content, Path::new("missing-content-types.pptx")) - .unwrap_err(); + let err = validate_content( + TemplateKind::Ppt, + &content, + Path::new("missing-content-types.pptx"), + ) + .unwrap_err(); assert!(matches!(err, TemplateError::InvalidFormat { .. })); assert!(err.to_string().contains("[Content_Types].xml")); } @@ -1138,7 +1162,8 @@ mod tests { content.extend_from_slice(PPTX_CONTENT_TYPES_ENTRY); content.resize(MIN_PPTX_SIZE + 10, 0xFF); - let err = validate_content(TemplateKind::Ppt, &content, Path::new("invalid-zip.pptx")).unwrap_err(); + let err = validate_content(TemplateKind::Ppt, &content, Path::new("invalid-zip.pptx")) + .unwrap_err(); assert!(matches!(err, TemplateError::InvalidFormat { .. })); assert!(err.to_string().contains("ZIP")); } @@ -1269,7 +1294,9 @@ mod tests { // Binary content should fail UTF-8 conversion with BinaryContent variant let err = template.content_as_str().unwrap_err(); assert!(matches!(err, TemplateError::BinaryContent { .. })); - assert!(err.to_string().contains("use content() for raw bytes instead")); + assert!(err + .to_string() + .contains("use content() for raw bytes instead")); } #[test] @@ -1433,7 +1460,8 @@ mod tests { #[test] fn test_validate_markdown_whitespace_only_rejected() { let content = b" \n\t\n \n"; - let err = validate_content(TemplateKind::Cr, content, Path::new("whitespace.md")).unwrap_err(); + let err = + validate_content(TemplateKind::Cr, content, Path::new("whitespace.md")).unwrap_err(); assert!(matches!(err, TemplateError::InvalidFormat { .. })); assert!(err.to_string().contains("whitespace")); } @@ -1507,28 +1535,25 @@ mod tests { #[test] fn test_content_as_str_non_utf8_markdown_returns_invalid_format() { - let template = LoadedTemplate::new_for_test( - TemplateKind::Cr, - "test.md", - vec![0xFF, 0xFE, 0x80, 0x81], - ); + let template = + LoadedTemplate::new_for_test(TemplateKind::Cr, "test.md", vec![0xFF, 0xFE, 0x80, 0x81]); let err = template.content_as_str().unwrap_err(); match err { TemplateError::InvalidFormat { kind, cause, .. } => { assert_eq!(kind, TemplateKind::Cr); assert!(cause.contains("UTF-8")); } - _ => panic!("Expected InvalidFormat for non-UTF-8 markdown, got {:?}", err), + _ => panic!( + "Expected InvalidFormat for non-UTF-8 markdown, got {:?}", + err + ), } } #[test] fn test_binary_content_variant_for_pptx() { - let template = LoadedTemplate::new_for_test( - TemplateKind::Ppt, - "test.pptx", - vec![0xFF; 100], - ); + let template = + LoadedTemplate::new_for_test(TemplateKind::Ppt, "test.pptx", vec![0xFF; 100]); let err = template.content_as_str().unwrap_err(); match err { TemplateError::BinaryContent { kind, hint, .. } => { diff --git a/crates/tf-config/tests/profile_tests.rs b/crates/tf-config/tests/profile_tests.rs index 2f0ac18..9b8505c 100644 --- a/crates/tf-config/tests/profile_tests.rs +++ b/crates/tf-config/tests/profile_tests.rs @@ -244,14 +244,19 @@ fn test_with_profile_rejects_path_traversal_in_output_folder() { // Applying the "evil" profile should fail because it sets output_folder to "../../../etc/passwd" let result = config.with_profile("evil"); - assert!(result.is_err(), "with_profile should reject path traversal in output_folder"); + assert!( + result.is_err(), + "with_profile should reject path traversal in output_folder" + ); let err = result.unwrap_err(); let err_msg = err.to_string(); // Error should mention output_folder and path traversal assert!( - err_msg.contains("output_folder") || err_msg.contains("path traversal") || err_msg.contains(".."), + err_msg.contains("output_folder") + || err_msg.contains("path traversal") + || err_msg.contains(".."), "Error should mention output_folder or path traversal, got: {}", err_msg ); @@ -265,14 +270,19 @@ fn test_with_profile_rejects_empty_output_folder() { // Applying the "empty_path" profile should fail let result = config.with_profile("empty_path"); - assert!(result.is_err(), "with_profile should reject empty output_folder"); + assert!( + result.is_err(), + "with_profile should reject empty output_folder" + ); let err = result.unwrap_err(); let err_msg = err.to_string(); // Error should mention output_folder assert!( - err_msg.contains("output_folder") || err_msg.contains("empty") || err_msg.contains("invalid"), + err_msg.contains("output_folder") + || err_msg.contains("empty") + || err_msg.contains("invalid"), "Error should mention output_folder issue, got: {}", err_msg ); @@ -292,20 +302,32 @@ fn test_with_profile_on_config_without_profiles_section() { let config = load_config(&fixture_path("minimal_config.yaml")).unwrap(); // Verify the config has no profiles - assert!(config.profiles.is_none(), "minimal_config should have no profiles section"); + assert!( + config.profiles.is_none(), + "minimal_config should have no profiles section" + ); // Try to apply any profile let result = config.with_profile("dev"); // Should return ProfileNotFound error - assert!(result.is_err(), "with_profile on config without profiles should fail"); + assert!( + result.is_err(), + "with_profile on config without profiles should fail" + ); let err = result.unwrap_err(); match &err { - ConfigError::ProfileNotFound { requested, available } => { + ConfigError::ProfileNotFound { + requested, + available, + } => { assert_eq!(requested, "dev"); // Available should be empty since no profiles defined - assert!(available.is_empty(), "Available profiles should be empty when no profiles section exists"); + assert!( + available.is_empty(), + "Available profiles should be empty when no profiles section exists" + ); } other => panic!("Expected ProfileNotFound, got: {:?}", other), } @@ -334,14 +356,20 @@ fn test_with_profile_rejects_invalid_jira_url_after_merge() { // Applying the "invalid_jira_url" profile should fail validation let result = config.with_profile("invalid_jira_url"); - assert!(result.is_err(), "with_profile should reject invalid Jira URL from profile"); + assert!( + result.is_err(), + "with_profile should reject invalid Jira URL from profile" + ); let err = result.unwrap_err(); let err_msg = err.to_string(); // Error should mention jira endpoint or URL validation failure assert!( - err_msg.contains("jira") || err_msg.contains("endpoint") || err_msg.contains("URL") || err_msg.contains("url"), + err_msg.contains("jira") + || err_msg.contains("endpoint") + || err_msg.contains("URL") + || err_msg.contains("url"), "Error should mention jira/endpoint/URL issue, got: {}", err_msg ); @@ -361,13 +389,19 @@ fn test_with_profile_rejects_invalid_llm_config_after_merge() { // Base config has a valid LLM config (mode: auto, cloud_enabled: false) let base_llm = config.llm.as_ref().expect("base config should have llm"); - assert!(!base_llm.cloud_enabled, "base config should have cloud_enabled=false"); + assert!( + !base_llm.cloud_enabled, + "base config should have cloud_enabled=false" + ); // Applying the "invalid_llm" profile should fail validation // because it sets mode=cloud but cloud_enabled=false let result = config.with_profile("invalid_llm"); - assert!(result.is_err(), "with_profile should reject invalid LLM config (mode=cloud, cloud_enabled=false)"); + assert!( + result.is_err(), + "with_profile should reject invalid LLM config (mode=cloud, cloud_enabled=false)" + ); let err = result.unwrap_err(); let err_msg = err.to_string(); @@ -388,11 +422,18 @@ fn test_with_profile_accepts_valid_llm_config() { // Applying the "valid_llm" profile should succeed let result = config.with_profile("valid_llm"); - assert!(result.is_ok(), "with_profile should accept valid LLM config: {:?}", result); + assert!( + result.is_ok(), + "with_profile should accept valid LLM config: {:?}", + result + ); let merged = result.unwrap(); let llm = merged.llm.as_ref().expect("merged config should have llm"); - assert_eq!(llm.local_endpoint.as_ref().unwrap(), "http://localhost:8080"); + assert_eq!( + llm.local_endpoint.as_ref().unwrap(), + "http://localhost:8080" + ); } // ============================================================================= @@ -412,7 +453,10 @@ fn test_profile_merge_overrides_templates() { let merged = config.with_profile("with_templates").unwrap(); // Templates should be overridden by the profile - let templates = merged.templates.as_ref().expect("templates should be present after profile merge"); + let templates = merged + .templates + .as_ref() + .expect("templates should be present after profile merge"); assert_eq!( templates.cr.as_ref().unwrap(), "./templates/dev/cr.md", @@ -449,8 +493,15 @@ fn test_profile_merge_overrides_llm_in_main_fixture() { let merged = config.with_profile("with_llm").unwrap(); // LLM should be overridden by the profile - let llm = merged.llm.as_ref().expect("llm should be present after profile merge"); - assert_eq!(format!("{}", llm.mode), "local", "llm.mode should be overridden by profile"); + let llm = merged + .llm + .as_ref() + .expect("llm should be present after profile merge"); + assert_eq!( + format!("{}", llm.mode), + "local", + "llm.mode should be overridden by profile" + ); assert_eq!( llm.local_endpoint.as_ref().unwrap(), "http://localhost:8080", @@ -476,20 +527,29 @@ fn test_with_profile_rejects_templates_path_traversal() { let config = load_config(&fixture_path("config_profile_invalid_templates.yaml")).unwrap(); // Base config has valid templates - let base_templates = config.templates.as_ref().expect("base config should have templates"); + let base_templates = config + .templates + .as_ref() + .expect("base config should have templates"); assert_eq!(base_templates.cr.as_ref().unwrap(), "./templates/cr.md"); // Applying the "evil_templates" profile should fail validation let result = config.with_profile("evil_templates"); - assert!(result.is_err(), "with_profile should reject templates with path traversal"); + assert!( + result.is_err(), + "with_profile should reject templates with path traversal" + ); let err = result.unwrap_err(); let err_msg = err.to_string(); // Error should mention templates or path traversal assert!( - err_msg.contains("templates") || err_msg.contains("cr") || err_msg.contains("path traversal") || err_msg.contains(".."), + err_msg.contains("templates") + || err_msg.contains("cr") + || err_msg.contains("path traversal") + || err_msg.contains(".."), "Error should mention templates/path traversal issue, got: {}", err_msg ); @@ -503,10 +563,17 @@ fn test_with_profile_accepts_valid_templates() { // Applying the "valid_templates" profile should succeed let result = config.with_profile("valid_templates"); - assert!(result.is_ok(), "with_profile should accept valid templates: {:?}", result); + assert!( + result.is_ok(), + "with_profile should accept valid templates: {:?}", + result + ); let merged = result.unwrap(); - let templates = merged.templates.as_ref().expect("merged config should have templates"); + let templates = merged + .templates + .as_ref() + .expect("merged config should have templates"); assert_eq!(templates.cr.as_ref().unwrap(), "./templates/dev/cr.md"); } @@ -519,20 +586,29 @@ fn test_with_profile_rejects_invalid_squash_url_after_merge() { let config = load_config(&fixture_path("config_profile_invalid_squash.yaml")).unwrap(); // Base config has a valid Squash URL - let base_squash = config.squash.as_ref().expect("base config should have squash"); + let base_squash = config + .squash + .as_ref() + .expect("base config should have squash"); assert_eq!(base_squash.endpoint, "https://squash.valid.example.com"); // Applying the "invalid_squash_url" profile should fail validation let result = config.with_profile("invalid_squash_url"); - assert!(result.is_err(), "with_profile should reject invalid Squash URL from profile"); + assert!( + result.is_err(), + "with_profile should reject invalid Squash URL from profile" + ); let err = result.unwrap_err(); let err_msg = err.to_string(); // Error should mention squash endpoint or URL validation failure assert!( - err_msg.contains("squash") || err_msg.contains("endpoint") || err_msg.contains("URL") || err_msg.contains("url"), + err_msg.contains("squash") + || err_msg.contains("endpoint") + || err_msg.contains("URL") + || err_msg.contains("url"), "Error should mention squash/endpoint/URL issue, got: {}", err_msg ); @@ -546,9 +622,16 @@ fn test_with_profile_accepts_valid_squash_url() { // Applying the "valid_squash" profile should succeed let result = config.with_profile("valid_squash"); - assert!(result.is_ok(), "with_profile should accept valid Squash config: {:?}", result); + assert!( + result.is_ok(), + "with_profile should accept valid Squash config: {:?}", + result + ); let merged = result.unwrap(); - let squash = merged.squash.as_ref().expect("merged config should have squash"); + let squash = merged + .squash + .as_ref() + .expect("merged config should have squash"); assert_eq!(squash.endpoint, "https://squash.staging.example.com"); } diff --git a/crates/tf-config/tests/profile_unit_tests.rs b/crates/tf-config/tests/profile_unit_tests.rs index 8c7284a..027269e 100644 --- a/crates/tf-config/tests/profile_unit_tests.rs +++ b/crates/tf-config/tests/profile_unit_tests.rs @@ -13,8 +13,8 @@ //! - AC #1: Profile merge logic and summary display //! - AC #3: ProfileOverride redacts secrets in Debug output -use tf_config::{JiraConfig, LlmConfig, LlmMode, ProjectConfig, SquashConfig, TemplatesConfig}; use tf_config::ProfileOverride; +use tf_config::{JiraConfig, LlmConfig, LlmMode, ProjectConfig, SquashConfig, TemplatesConfig}; // ============================================================================= // Test 1: ProfileOverride with jira override redacts token in Debug @@ -265,9 +265,15 @@ fn test_empty_profile_preserves_base_config() { let llm = merged.llm.as_ref().expect("llm should be preserved"); assert_eq!(llm.mode, LlmMode::Local); - assert_eq!(llm.local_endpoint.as_ref().unwrap(), "http://localhost:11434"); + assert_eq!( + llm.local_endpoint.as_ref().unwrap(), + "http://localhost:11434" + ); - let templates = merged.templates.as_ref().expect("templates should be preserved"); + let templates = merged + .templates + .as_ref() + .expect("templates should be preserved"); assert_eq!(templates.cr.as_ref().unwrap(), "./templates/cr.md"); } @@ -349,7 +355,10 @@ fn test_partial_override_only_changes_specified_fields() { assert_eq!(llm.api_key.as_ref().unwrap(), "sk-prod-key"); // templates should be unchanged - let templates = merged.templates.as_ref().expect("templates should be preserved"); + let templates = merged + .templates + .as_ref() + .expect("templates should be preserved"); assert_eq!(templates.cr.as_ref().unwrap(), "./templates/cr.md"); } @@ -587,7 +596,10 @@ fn test_active_profile_summary_no_profile() { // Summary should indicate no profile is active assert!( - summary.contains("none") || summary.contains("None") || summary.contains("No profile") || summary.contains("default"), + summary.contains("none") + || summary.contains("None") + || summary.contains("No profile") + || summary.contains("default"), "Summary should indicate no active profile, got: {}", summary ); @@ -679,7 +691,10 @@ fn test_profile_override_partial_eq() { templates: None, output_folder: Some("./different-output".to_string()), }; - assert_ne!(profile1, profile3, "Different output_folder should not be equal"); + assert_ne!( + profile1, profile3, + "Different output_folder should not be equal" + ); // Default profiles should be equal let default1 = ProfileOverride::default(); diff --git a/crates/tf-logging/src/config.rs b/crates/tf-logging/src/config.rs index 82973c3..b658a3d 100644 --- a/crates/tf-logging/src/config.rs +++ b/crates/tf-logging/src/config.rs @@ -50,7 +50,8 @@ mod tests { let temp = tempdir().unwrap(); let config_path = temp.path().join("config.yaml"); let mut file = fs::File::create(&config_path).unwrap(); - file.write_all(b"project_name: \"test-project\"\noutput_folder: \"/tmp/test-output\"\n").unwrap(); + file.write_all(b"project_name: \"test-project\"\noutput_folder: \"/tmp/test-output\"\n") + .unwrap(); file.flush().unwrap(); let project_config = tf_config::load_config(&config_path).unwrap(); @@ -69,8 +70,11 @@ mod tests { let project_config: tf_config::ProjectConfig = serde_yaml::from_str(yaml).unwrap(); let logging_config = LoggingConfig::from_project_config(&project_config); assert_eq!(logging_config.log_dir, "/tmp/test-output/logs"); - assert!(!logging_config.log_dir.contains("//"), - "log_dir should not contain double slashes, got: {}", logging_config.log_dir); + assert!( + !logging_config.log_dir.contains("//"), + "log_dir should not contain double slashes, got: {}", + logging_config.log_dir + ); } #[test] diff --git a/crates/tf-logging/src/error.rs b/crates/tf-logging/src/error.rs index f3d58f5..d5af667 100644 --- a/crates/tf-logging/src/error.rs +++ b/crates/tf-logging/src/error.rs @@ -12,10 +12,7 @@ pub enum LoggingError { /// (e.g., global subscriber already set). Currently not returned by `init_logging()` /// which uses thread-local dispatch (`set_default`) that cannot fail. #[error("Failed to initialize logging: {cause}. {hint}")] - InitFailed { - cause: String, - hint: String, - }, + InitFailed { cause: String, hint: String }, /// Failed to create the log output directory. #[error("Failed to create log directory '{path}': {cause}. {hint}")] @@ -27,10 +24,7 @@ pub enum LoggingError { /// An invalid log level string was provided. #[error("Invalid log level '{level}'. {hint}")] - InvalidLogLevel { - level: String, - hint: String, - }, + InvalidLogLevel { level: String, hint: String }, } #[cfg(test)] @@ -43,13 +37,17 @@ mod tests { fn test_logging_error_init_failed_has_actionable_hint() { let error = LoggingError::InitFailed { cause: "tracing subscriber already set".to_string(), - hint: "Check that the log directory is writable and tracing is not already initialized".to_string(), + hint: "Check that the log directory is writable and tracing is not already initialized" + .to_string(), }; let display = error.to_string(); // Verify cause and hint appear in display - assert!(display.contains("tracing subscriber already set"), "Display missing cause"); + assert!( + display.contains("tracing subscriber already set"), + "Display missing cause" + ); assert!( display.contains("Check that the log directory is writable"), "Display missing actionable hint" @@ -71,8 +69,14 @@ mod tests { let display = error.to_string(); - assert!(display.contains("/invalid/path/logs"), "Display missing path"); - assert!(display.contains("permission denied"), "Display missing cause"); + assert!( + display.contains("/invalid/path/logs"), + "Display missing path" + ); + assert!( + display.contains("permission denied"), + "Display missing cause" + ); assert!( display.contains("Verify permissions on the parent directory"), "Display missing actionable hint" From 027ca66358434181554a06e6a9eb220628faad6d Mon Sep 17 00:00:00 2001 From: Edouard Zemb Date: Sun, 8 Feb 2026 21:47:37 +0100 Subject: [PATCH 2/2] chore(sprint): add story 0-5b and change proposal for test refactoring Add sprint change proposal documenting TEA quality findings (maintainability score 45/100) and approved remediation plan for tf-config test suite. Update sprint-status.yaml with new story 0-5b entry (backlog). Co-Authored-By: Claude Opus 4.6 --- .../sprint-status.yaml | 1 + .../sprint-change-proposal-2026-02-08.md | 312 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 _bmad-output/planning-artifacts/sprint-change-proposal-2026-02-08.md diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index ffb19bd..8699266 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -47,6 +47,7 @@ development_status: 0-3-gestion-des-secrets-via-secret-store: done 0-4-charger-des-templates-cr-ppt-anomalies: done 0-5-journalisation-baseline-sans-donnees-sensibles: done + 0-5b-refactoring-test-suite-tf-config: backlog 0-6-configurer-checklist-de-testabilite-et-regles-de-scoring: backlog 0-7-anonymisation-automatique-avant-envoi-cloud: backlog epic-0-retrospective: optional diff --git a/_bmad-output/planning-artifacts/sprint-change-proposal-2026-02-08.md b/_bmad-output/planning-artifacts/sprint-change-proposal-2026-02-08.md new file mode 100644 index 0000000..c9046d5 --- /dev/null +++ b/_bmad-output/planning-artifacts/sprint-change-proposal-2026-02-08.md @@ -0,0 +1,312 @@ +# Sprint Change Proposal - Test Quality Remediation + +**Date:** 2026-02-08 +**Trigger:** TEA/QA workflow recommendations post-Story 0-5 +**Scope Classification:** Minor +**Status:** Approved (2026-02-08) +**Approved By:** Edouard + +--- + +## Section 1: Issue Summary + +### Problem Statement + +The TEA quality workflows (test-review, traceability-trace, nfr-assess) executed after Story 0-5 completion identified significant test maintainability debt in the tf-config crate test suite. The primary issues are: + +1. **Monolithic test module** (3231 lines, 211 tests) — 10x the recommended 300-line threshold +2. **Extreme copy-paste duplication** — 80+ tests follow identical YAML-load-assert-error pattern without parameterization +3. **Review-round organization** — Tests organized by AI review round numbers ("Review 5", "Review 12") instead of functional grouping (URL validation, path traversal, serde errors) + +### Discovery Context + +- **When:** 2026-02-07/08, after Story 0-5 merge +- **How:** Automated TEA workflows: `testarch-test-review`, `testarch-trace`, `testarch-nfr` +- **Story:** 0-5 "Journalisation baseline sans donnees sensibles" (status: done) + +### Evidence + +| Metric | Value | Threshold | Status | +|--------|-------|-----------|--------| +| Test Review Score | 81/100 (B) | >= 85 | Below target | +| Maintainability Score | 45/100 (F) | >= 70 | Critical | +| NFR Assessment | 18/23 (78%) | >= 90% | CONCERNS | +| High Priority Issues | 3 | 0 | Action needed | +| Evidence Gaps | 4 | 0 | Action needed | + +**Source artifacts:** +- `_bmad-output/test-review.md` (Test Quality Review, 2026-02-07) +- `_bmad-output/traceability-matrix.md` (Traceability & Gate Decision, 2026-02-08) +- `_bmad-output/nfr-assessment.md` (NFR Assessment, 2026-02-07) + +--- + +## Section 2: Impact Analysis + +### Epic Impact + +| Epic | Status | Impact | Detail | +|------|--------|--------|--------| +| **Epic 0: Foundation & Access** | in-progress | **Direct** | Insert technical story between 0-5 (done) and 0-6 (backlog) | +| Epic 1: Triage & readiness | backlog | Indirect | Benefits from improved test patterns for tf-connectors | +| Epic 7: Conformite & securite | backlog | Indirect | serde_yaml deprecation to address before stabilization | +| Epics 2-6 | backlog | None | No impact | + +### Story Impact + +**Current stories affected:** +- Story 0-6 (backlog): Delayed by ~1 day while technical story is executed +- Stories 0-1 through 0-5 (done): No retroactive changes to functionality + +**New story required:** +- Story 0-5b: "Refactor tf-config test suite for maintainability" (see Section 4 for details) + +### Artifact Conflicts + +| Artifact | Conflict | Action Required | +|----------|----------|-----------------| +| PRD (`prd.md`) | None | No changes | +| Architecture (`architecture.md`) | None | No changes | +| UX Design | N/A | No changes | +| Epics (`epics.md`) | Minor | Add technical story to Epic 0 | +| Sprint Status (`sprint-status.yaml`) | Minor | Add story entry | +| CI Pipeline (`.github/workflows/test.yml`) | None now | Coverage step added later (backlog) | +| Test Design docs | None | Test behavior unchanged, only structure | + +### Technical Impact + +- **Code changes:** Test files only (`#[cfg(test)]` modules). Zero production code changes. +- **Risk:** Very low — `cargo test --workspace` validates at every step +- **Infrastructure:** None +- **Deployment:** None + +--- + +## Section 3: Recommended Approach + +### Selected Path: Direct Adjustment + +Insert a technical story into Epic 0 to address P0/P1 test quality findings before proceeding with Story 0-6. + +### Rationale + +1. **Prevents debt accumulation:** Story 0-6 (checklist + scoring config) will add more tests to tf-config — better to clean up before adding more +2. **Contained effort:** ~1 day of work, well-defined scope (4 items) +3. **Near-zero risk:** Test refactoring only, validated by existing passing test suite (417 tests) +4. **Measurable improvement:** Maintainability 45/100 -> estimated 75-80/100 +5. **No disruption:** Natural insertion point between completed and upcoming stories + +### Alternatives Considered + +| Option | Assessment | Reason for Rejection | +|--------|------------|---------------------| +| Rollback | Not viable | Tests work correctly; issue is structure, not correctness | +| PRD MVP Review | Not viable | Disproportionate — no functional impact | +| Do nothing | Risky | Debt worsens with every future story touching tf-config | +| Defer to post-Epic 0 | Suboptimal | 0-6 and 0-7 will add more tests, making refactoring harder | + +### Effort Estimate + +- **Story technical scope:** ~1 day (P0/P1 items) +- **Risk level:** Low +- **Timeline impact:** Minimal — 1 day delay on Story 0-6 start + +--- + +## Section 4: Detailed Change Proposals + +### Proposal 1: Split monolithic test module (P0) + +``` +Story: tf-config test suite +Section: crates/tf-config/src/config.rs (lines 2003-5233) + +OLD: +#[cfg(test)] +mod tests { + // 3231 lines, 211 tests + // === REVIEW 5 TESTS === + // === REVIEW 6 TESTS === + // ... (18+ review-round sections) +} + +NEW: +#[cfg(test)] +mod tests { + mod url_validation; // ~50 tests: URL scheme, IPv6, whitespace + mod path_validation; // ~30 tests: traversal, null bytes, formats + mod serde_errors; // ~40 tests: type errors, missing fields + mod llm_config; // ~25 tests: cloud mode, local mode, defaults + mod redact_url; // ~30 tests: URL parameter redaction + mod config_loading; // ~15 tests: load_config, fixtures + mod profile_summary; // ~10 tests: active_profile_summary, check_output_folder + mod helpers; // create_temp_config, common assertions +} + +Rationale: Navigate, understand, and maintain tests by functional area +instead of by when they were written. Each sub-module stays under 500 lines. +``` + +### Proposal 2: Extract parameterized test macro (P0) + +``` +Story: tf-config test suite +Section: crates/tf-config/src/config.rs (80+ duplicated tests) + +OLD (repeated 80+ times): +#[test] +fn test_url_scheme_only_rejected() { + let yaml = "project_name: \"test\"\noutput_folder: \"./out\"\njira:\n endpoint: \"http://\""; + let result = load_config(&create_temp_config(yaml)); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("URL"), "Expected URL error: {}", err); +} + +NEW: +macro_rules! test_config_rejects { + ($name:ident, $yaml:expr, $($expected:expr),+) => { + #[test] + fn $name() { + let result = load_config(&create_temp_config($yaml)); + assert!(result.is_err(), "Expected rejection for {}", stringify!($name)); + let err = result.unwrap_err().to_string(); + $( + assert!(err.contains($expected), + "Error should contain '{}': got '{}'", $expected, err); + )+ + } + }; +} + +test_config_rejects!(test_url_scheme_only_rejected, + "project_name: \"test\"\noutput_folder: \"./out\"\njira:\n endpoint: \"http://\"", + "URL" +); +// ... ~80 more invocations + +Rationale: Eliminates ~2000 lines of boilerplate. Pattern already proven +in tf-logging/redact.rs (test_sensitive_field_redacted! macro). +``` + +### Proposal 3: Reorganize by functionality (P1) + +``` +Story: tf-config test suite +Section: All test section headers + +OLD: +// === REVIEW 5 TESTS === +// === REVIEW 6 TESTS: IPv6 URL validation === +// === REVIEW 12 TESTS: Boolean type errors, URL sensitive params === + +NEW: +// === URL Validation === +// === Path Traversal Protection === +// === Cloud Mode Requirements === +// === Serde Error Messages === + +Rationale: Developers can find all tests for a feature in one place. +Currently URL validation tests are scattered across Reviews 5, 6, 9, 12, 13, 14, 18, 22, 23. +``` + +### Proposal 4: Normalize temp file management (P1) + +``` +Story: tf-config test suite +Section: crates/tf-config/src/config.rs (6 whitespace endpoint tests, lines ~4778-4905) + +OLD: +let path = std::env::temp_dir().join("test_ws_endpoint.yaml"); +std::fs::write(&path, yaml).unwrap(); +let result = load_config(&path); +std::fs::remove_file(&path).ok(); + +NEW: +let path = create_temp_config(yaml); +let result = load_config(&path); +// cleanup handled automatically by tempfile + +Rationale: Manual cleanup not guaranteed on panic. +create_temp_config() helper already used by 200+ other tests. +``` + +### Follow-up Items (P2-P3, not in technical story) + +| # | Item | Priority | Timing | +|---|------|----------|--------| +| 5 | Extract `test_logging_config()` helper in tf-logging | P2 | Next story | +| 6 | Replace timestamp with AtomicU64 in `unique_key()` | P2 | Next story | +| 7 | Add NaN/Infinity test for `record_f64` | P2 | Next story | +| 8 | Configure cargo-tarpaulin for coverage measurement | P2 | Backlog | +| 9 | RAII `KeyGuard` for keyring tests | P3 | Backlog | +| 10 | Migrate from serde_yaml (deprecated) | P3 | Pre-Epic 7 | +| 11 | Implement log retention/purge (NFR4) | P3 | Story 7-2 | + +--- + +## Section 5: Implementation Handoff + +### Change Scope: Minor + +Direct implementation by development team. No backlog reorganization or architectural replan needed. + +### Handoff Plan + +| Role | Agent | Responsibility | +|------|-------|----------------| +| **SM** | `bmad-agent-bmm-sm` | Create story file, update sprint-status.yaml, update epics.md | +| **Dev** | `bmad-agent-bmm-dev` | Execute technical story (Proposals 1-4) | +| **TEA** | `bmad-agent-tea-tea` | Re-run `testarch-test-review` post-refactoring to validate improvement | + +### Implementation Sequence + +1. SM creates story file with acceptance criteria derived from Proposals 1-4 +2. Dev executes story using `dev-story` workflow +3. Dev runs `cargo test --workspace` to validate 0 regressions +4. TEA runs `testarch-test-review` to measure new maintainability score +5. SM updates sprint-status.yaml to mark story as done + +### Success Criteria + +| Criterion | Target | Measurement | +|-----------|--------|-------------| +| Test regressions | 0 | `cargo test --workspace` — all 417+ tests pass | +| Max sub-module size | < 500 lines | Line count per test sub-module | +| Maintainability score | >= 70/100 (target 80) | TEA `testarch-test-review` re-run | +| Macro coverage | >= 80% of validation tests | Count of `test_config_rejects!` invocations vs total validation tests | +| clippy clean | 0 warnings | `cargo clippy --workspace -- -D warnings` | + +### Definition of Done + +- [ ] config.rs test module split into functional sub-modules +- [ ] `test_config_rejects!` macro extracted and applied to duplicated tests +- [ ] Test headers reorganized by functionality +- [ ] 6 whitespace endpoint tests normalized to `create_temp_config()` +- [ ] `cargo test --workspace` passes with 0 regressions +- [ ] `cargo clippy` passes clean +- [ ] TEA test-review re-run confirms maintainability improvement + +--- + +## Appendix: Recommendation Traceability + +| # | Recommendation | Source Document | Source Section | Proposal | +|---|---------------|-----------------|----------------|----------| +| 1 | Split monolithic test module | test-review.md | Critical Issue #1 | Proposal 1 | +| 2 | Extract parameterized test macro | test-review.md | Critical Issue #2 | Proposal 2 | +| 3 | Reorganize by functionality | test-review.md | Critical Issue #3 | Proposal 3 | +| 4 | Normalize temp file management | test-review.md | Recommendation #4 | Proposal 4 | +| 5 | Extract test_logging_config helper | test-review.md | Recommendation #1 | Follow-up P2 | +| 6 | Replace timestamp with AtomicU64 | test-review.md | Recommendation #2 | Follow-up P2 | +| 7 | Add NaN/Infinity record_f64 test | test-review.md | Recommendation #3 | Follow-up P2 | +| 8 | Configure coverage measurement | nfr-assessment.md | Evidence Gap #3 | Follow-up P2 | +| 9 | RAII KeyGuard for keyring | test-review.md | Recommendation #5 | Follow-up P3 | +| 10 | Migrate serde_yaml | nfr-assessment.md | Recommendation #5 | Follow-up P3 | +| 11 | Log retention/purge NFR4 | nfr-assessment.md | Recommendation #6 | Story 7-2 | + +--- + +**Generated:** 2026-02-08 +**Workflow:** correct-course (BMad Method) +**Trigger:** TEA quality workflow recommendations post-Story 0-5