diff --git a/crates/diffguard-lsp/src/server.rs b/crates/diffguard-lsp/src/server.rs index 51b37853..fc7763bf 100644 --- a/crates/diffguard-lsp/src/server.rs +++ b/crates/diffguard-lsp/src/server.rs @@ -959,6 +959,86 @@ fn uri_to_file_path(uri: &Uri) -> Option { #[cfg(test)] mod tests { use super::*; + use lsp_types::{ + DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, TextDocumentIdentifier, + TextDocumentItem, VersionedTextDocumentIdentifier, + }; + + fn sample_rule(id: &str, url: Option<&str>) -> diffguard_types::RuleConfig { + diffguard_types::RuleConfig { + id: id.to_string(), + description: String::new(), + severity: Severity::Warn, + message: "msg".to_string(), + languages: vec![], + patterns: vec!["x".to_string()], + paths: vec![], + exclude_paths: vec![], + ignore_comments: false, + ignore_strings: false, + match_mode: diffguard_types::MatchMode::Any, + multiline: false, + multiline_window: None, + context_patterns: vec![], + context_window: None, + escalate_patterns: vec![], + escalate_window: None, + escalate_to: None, + depends_on: vec![], + help: None, + url: url.map(|s| s.to_string()), + tags: vec![], + test_cases: vec![], + } + } + + fn config_with_rules(rules: Vec) -> ConfigFile { + ConfigFile { + includes: vec![], + defaults: diffguard_types::Defaults::default(), + rule: rules, + } + } + + fn parse_uri(uri_str: &str) -> Uri { + uri_str.parse().expect("uri parse") // diffguard: ignore rust.no_unwrap + } + + fn drain_messages(connection: &Connection) -> Vec { + let mut messages = Vec::new(); + while let Ok(msg) = connection.receiver.try_recv() { + messages.push(msg); + } + messages + } + + fn find_response(messages: &[Message]) -> Option<&Response> { + messages.iter().find_map(|m| match m { + Message::Response(response) => Some(response), + _ => None, + }) + } + + fn find_notification<'a>(messages: &'a [Message], method: &str) -> Option<&'a Notification> { + messages.iter().find_map(|m| match m { + Message::Notification(notif) if notif.method == method => Some(notif), + _ => None, + }) + } + + fn empty_state() -> ServerState { + ServerState { + workspace_root: None, + config_path: None, + no_default_rules: true, + max_findings: DEFAULT_MAX_FINDINGS, + force_language: None, + config: ConfigFile::built_in(), + documents: HashMap::new(), + git_support: GitSupport::Unknown, + } + } #[test] fn server_capabilities_include_text_sync_and_actions() { @@ -973,11 +1053,11 @@ mod tests { #[test] fn initialize_payload_contains_server_name() { - let value = initialize_payload().expect("payload"); + let value = initialize_payload().expect("payload"); // diffguard: ignore rust.no_unwrap let info = value .get("serverInfo") .and_then(|v| v.as_object()) - .expect("server info"); + .expect("server info"); // diffguard: ignore rust.no_unwrap assert_eq!( info.get("name").and_then(|v| v.as_str()), Some("diffguard-lsp") @@ -986,39 +1066,14 @@ mod tests { #[test] fn build_code_actions_contains_explain_action() { - let config = ConfigFile { - includes: vec![], - defaults: diffguard_types::Defaults::default(), - rule: vec![diffguard_types::RuleConfig { - id: "rust.no_unwrap".to_string(), - description: String::new(), - severity: Severity::Warn, - message: "Avoid unwrap".to_string(), - languages: vec![], - patterns: vec!["unwrap".to_string()], - paths: vec![], - exclude_paths: vec![], - ignore_comments: false, - ignore_strings: false, - match_mode: diffguard_types::MatchMode::Any, - multiline: false, - multiline_window: None, - context_patterns: vec![], - context_window: None, - escalate_patterns: vec![], - escalate_window: None, - escalate_to: None, - depends_on: vec![], - help: None, - url: Some("https://example.com/rule".to_string()), - tags: vec![], - test_cases: vec![], - }], - }; + let config = config_with_rules(vec![sample_rule( + "rust.no_unwrap", + Some("https://example.com/rule"), + )]); let params = CodeActionParams { text_document: lsp_types::TextDocumentIdentifier { - uri: "file:///tmp/test.rs".parse().expect("uri"), + uri: parse_uri("file:///tmp/test.rs"), }, range: Range::new(Position::new(0, 0), Position::new(0, 10)), context: lsp_types::CodeActionContext { @@ -1036,4 +1091,876 @@ mod tests { let actions = build_code_actions(&config, ¶ms); assert!(!actions.is_empty()); } + + // --------------------------------------------------------------------- + // Pure-function helpers + // --------------------------------------------------------------------- + + #[test] + fn normalize_option_string_returns_none_for_whitespace_input() { + assert_eq!(normalize_option_string(None), None); + assert_eq!(normalize_option_string(Some(String::new())), None); + assert_eq!(normalize_option_string(Some(" ".to_string())), None); + assert_eq!(normalize_option_string(Some("\t\n ".to_string())), None); + assert_eq!( + normalize_option_string(Some(" rust ".to_string())), + Some("rust".to_string()) + ); + } + + #[test] + fn parse_init_options_returns_default_when_missing_or_invalid() { + let none_opts = parse_init_options(None); + assert!(none_opts.config_path.is_none()); + assert!(!none_opts.no_default_rules); + assert!(none_opts.max_findings.is_none()); + + // An object that doesn't deserialize to InitOptions falls back to default. + let bogus = serde_json::json!({ "configPath": 42 }); + let defaulted = parse_init_options(Some(&bogus)); + assert!(defaulted.config_path.is_none()); + } + + #[test] + fn parse_init_options_reads_camel_case_fields() { + let value = serde_json::json!({ + "configPath": "config.toml", + "noDefaultRules": true, + "maxFindings": 17, + "forceLanguage": "rust", + }); + let opts = parse_init_options(Some(&value)); + assert_eq!(opts.config_path.as_deref(), Some("config.toml")); + assert!(opts.no_default_rules); + assert_eq!(opts.max_findings, Some(17)); + assert_eq!(opts.force_language.as_deref(), Some("rust")); + } + + #[test] + fn nth_string_arg_returns_none_for_missing_or_wrong_type() { + let args = vec![json!("first"), json!(42), json!(null)]; + assert_eq!(nth_string_arg(&args, 0), Some("first".to_string())); + // Non-string value + assert_eq!(nth_string_arg(&args, 1), None); + // null value + assert_eq!(nth_string_arg(&args, 2), None); + // out-of-bounds index + assert_eq!(nth_string_arg(&args, 5), None); + // empty list + assert_eq!(nth_string_arg(&[], 0), None); + } + + #[test] + fn uri_to_file_path_returns_none_for_malformed_uri() { + // A scheme without authority is not a valid file URL. + let uri: Uri = parse_uri("https://example.com/foo.rs"); + assert!(uri_to_file_path(&uri).is_none()); + } + + #[test] + fn uri_to_file_path_parses_valid_file_uri() { + let uri: Uri = parse_uri("file:///tmp/some/path.rs"); + let parsed = uri_to_file_path(&uri).expect("file path"); // diffguard: ignore rust.no_unwrap + assert_eq!(parsed, PathBuf::from("/tmp/some/path.rs")); + } + + #[test] + fn explain_rule_message_reports_found_when_rule_exists() { + let config = config_with_rules(vec![sample_rule("rust.no_unwrap", None)]); + let (message, found) = explain_rule_message(&config, "rust.no_unwrap"); + assert!(found); + assert!(message.contains("Rule: rust.no_unwrap")); + } + + #[test] + fn explain_rule_message_includes_did_you_mean_when_rule_missing() { + let config = config_with_rules(vec![sample_rule("rust.no_unwrap", None)]); + let (message, found) = explain_rule_message(&config, "rust.no_unwra"); + assert!(!found); + assert!(message.contains("not found")); + assert!(message.contains("Did you mean:")); + assert!(message.contains("rust.no_unwrap")); + } + + #[test] + fn explain_rule_message_omits_suggestions_when_no_similar_rules() { + let config = config_with_rules(vec![sample_rule("rust.no_unwrap", None)]); + let (message, found) = explain_rule_message(&config, "totally.unrelated_thing_xyz"); + assert!(!found); + assert!(message.contains("not found")); + assert!(!message.contains("Did you mean:")); + } + + #[test] + fn findings_to_diagnostics_maps_severity_and_sorts_by_position() { + let findings = vec![ + Finding { + rule_id: "r1".to_string(), + severity: Severity::Info, + message: "info-msg".to_string(), + path: "src/a.rs".to_string(), + line: 3, + column: Some(2), + match_text: "ab".to_string(), + snippet: String::new(), + }, + Finding { + rule_id: "r2".to_string(), + severity: Severity::Error, + message: "err-msg".to_string(), + path: "src/a.rs".to_string(), + line: 1, + column: Some(1), + match_text: "y".to_string(), + snippet: String::new(), + }, + Finding { + rule_id: "r3".to_string(), + severity: Severity::Warn, + message: "warn-msg".to_string(), + path: "src/a.rs".to_string(), + line: 2, + column: None, + match_text: String::new(), + snippet: String::new(), + }, + ]; + + let diagnostics = findings_to_diagnostics(&findings); + assert_eq!(diagnostics.len(), 3); + // Sorted ascending by line. + assert_eq!(diagnostics[0].range.start.line, 0); + assert_eq!(diagnostics[1].range.start.line, 1); + assert_eq!(diagnostics[2].range.start.line, 2); + assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR)); + assert_eq!(diagnostics[1].severity, Some(DiagnosticSeverity::WARNING)); + assert_eq!( + diagnostics[2].severity, + Some(DiagnosticSeverity::INFORMATION) + ); + // Empty match_text yields a span of length 1. + assert_eq!(diagnostics[1].range.end.character, 1); + } + + // --------------------------------------------------------------------- + // DocumentState behaviour + // --------------------------------------------------------------------- + + #[test] + fn document_state_apply_changes_handles_empty_change_list() { + let mut doc = DocumentState::new(PathBuf::from("a.rs"), 1, "hello\n".to_string()); + doc.apply_changes(&[]).expect("apply ok"); // diffguard: ignore rust.no_unwrap + assert_eq!(doc.text, "hello\n"); + assert!(doc.changed_lines.is_empty()); + } + + #[test] + fn document_state_apply_changes_replaces_text_on_full_change() { + let mut doc = DocumentState::new(PathBuf::from("a.rs"), 1, "before\n".to_string()); + let change = TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "after\n".to_string(), + }; + doc.apply_changes(&[change]).expect("apply ok"); // diffguard: ignore rust.no_unwrap + assert_eq!(doc.text, "after\n"); + assert!(!doc.changed_lines.is_empty()); + } + + #[test] + fn document_state_apply_changes_uses_last_full_replacement() { + let mut doc = DocumentState::new(PathBuf::from("a.rs"), 1, "orig\n".to_string()); + let changes = vec![ + TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "first\n".to_string(), + }, + TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "final\n".to_string(), + }, + ]; + doc.apply_changes(&changes).expect("apply ok"); // diffguard: ignore rust.no_unwrap + assert_eq!(doc.text, "final\n"); + } + + #[test] + fn document_state_mark_saved_with_text_updates_baseline_and_clears_changes() { + let mut doc = DocumentState::new(PathBuf::from("a.rs"), 1, "before\n".to_string()); + let change = TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "after\n".to_string(), + }; + doc.apply_changes(&[change]).expect("apply ok"); // diffguard: ignore rust.no_unwrap + assert!(!doc.changed_lines.is_empty()); + + doc.mark_saved(Some("saved\n".to_string())); + assert_eq!(doc.text, "saved\n"); + assert_eq!(doc.baseline_text, "saved\n"); + assert!(doc.changed_lines.is_empty()); + } + + #[test] + fn document_state_mark_saved_without_text_keeps_current_text() { + let mut doc = DocumentState::new(PathBuf::from("a.rs"), 1, "before\n".to_string()); + let change = TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "after\n".to_string(), + }; + doc.apply_changes(&[change]).expect("apply ok"); // diffguard: ignore rust.no_unwrap + + doc.mark_saved(None); + assert_eq!(doc.text, "after\n"); + assert_eq!(doc.baseline_text, "after\n"); + assert!(doc.changed_lines.is_empty()); + } + + // --------------------------------------------------------------------- + // extract_workspace_root variants + // --------------------------------------------------------------------- + + #[test] + #[allow(deprecated)] + fn extract_workspace_root_prefers_workspace_folders() { + let params = InitializeParams { + workspace_folders: Some(vec![lsp_types::WorkspaceFolder { + uri: parse_uri("file:///tmp/ws"), + name: "ws".to_string(), + }]), + ..InitializeParams::default() + }; + let root = extract_workspace_root(¶ms); + assert_eq!(root, Some(PathBuf::from("/tmp/ws"))); + } + + #[test] + #[allow(deprecated)] + fn extract_workspace_root_falls_back_to_root_uri() { + let params = InitializeParams { + workspace_folders: None, + root_uri: Some(parse_uri("file:///tmp/ws-from-uri")), + ..InitializeParams::default() + }; + let root = extract_workspace_root(¶ms); + assert_eq!(root, Some(PathBuf::from("/tmp/ws-from-uri"))); + } + + #[test] + #[allow(deprecated)] + fn extract_workspace_root_falls_back_to_root_path() { + let params = InitializeParams { + workspace_folders: None, + root_uri: None, + root_path: Some("/tmp/legacy".to_string()), + ..InitializeParams::default() + }; + let root = extract_workspace_root(¶ms); + assert_eq!(root, Some(PathBuf::from("/tmp/legacy"))); + } + + #[test] + #[allow(deprecated)] + fn extract_workspace_root_returns_none_when_nothing_provided() { + let params = InitializeParams::default(); + assert!(extract_workspace_root(¶ms).is_none()); + } + + // --------------------------------------------------------------------- + // ServerState::from_initialize + // --------------------------------------------------------------------- + + #[test] + fn from_initialize_uses_default_max_findings_when_unset() { + let params = InitializeParams::default(); + let (state, _warning) = ServerState::from_initialize(¶ms); + assert_eq!(state.max_findings, DEFAULT_MAX_FINDINGS); + assert!(state.workspace_root.is_none()); + } + + #[test] + fn from_initialize_clamps_zero_max_findings_to_one() { + let params = InitializeParams { + initialization_options: Some(json!({ "maxFindings": 0 })), + ..InitializeParams::default() + }; + let (state, _warning) = ServerState::from_initialize(¶ms); + assert_eq!(state.max_findings, 1); + } + + #[test] + fn from_initialize_passes_force_language_through_trim() { + let params = InitializeParams { + initialization_options: Some(json!({ "forceLanguage": " rust " })), + ..InitializeParams::default() + }; + let (state, _warning) = ServerState::from_initialize(¶ms); + assert_eq!(state.force_language.as_deref(), Some("rust")); + } + + #[test] + fn from_initialize_emits_warning_when_config_path_fails_to_load() { + let params = InitializeParams { + initialization_options: Some(json!({ + "configPath": "/nonexistent/diffguard-missing.toml" + })), + ..InitializeParams::default() + }; + let (state, warning) = ServerState::from_initialize(¶ms); + // Falls back to built-in rules when load fails. + assert!(!state.config.rule.is_empty()); + assert!(warning.is_some()); + let warning_text = warning.expect("warning string"); // diffguard: ignore rust.no_unwrap + assert!(warning_text.contains("failed to load config")); + } + + // --------------------------------------------------------------------- + // is_config_uri + // --------------------------------------------------------------------- + + #[test] + fn is_config_uri_returns_false_when_no_config_path() { + let state = empty_state(); + let uri = parse_uri("file:///tmp/foo.rs"); + assert!(!is_config_uri(&state, &uri)); + } + + #[test] + fn is_config_uri_returns_false_when_uri_cannot_be_parsed() { + let mut state = empty_state(); + state.config_path = Some(PathBuf::from("/tmp/diffguard.toml")); + let uri = parse_uri("https://example.com/foo.toml"); + assert!(!is_config_uri(&state, &uri)); + } + + #[test] + fn is_config_uri_matches_normalized_paths() { + let mut state = empty_state(); + state.config_path = Some(PathBuf::from("/tmp/diffguard.toml")); + let uri = parse_uri("file:///tmp/diffguard.toml"); + assert!(is_config_uri(&state, &uri)); + } + + // --------------------------------------------------------------------- + // reload_config + // --------------------------------------------------------------------- + + #[test] + fn reload_config_reports_rule_count_on_success() { + let mut state = empty_state(); + // With no config_path and no_default_rules=true, load returns built_in but + // we set no_default_rules=true so we get an empty/built-in-less config. + state.no_default_rules = true; + state.config_path = None; + let result = reload_config(&mut state).expect("reload ok"); // diffguard: ignore rust.no_unwrap + assert!(result.contains("config reloaded")); + } + + #[test] + fn reload_config_resets_to_built_in_on_failure() { + let mut state = empty_state(); + state.config_path = Some(PathBuf::from("/nonexistent/diffguard-missing.toml")); + state.git_support = GitSupport::Available; + let err = reload_config(&mut state).expect_err("reload should fail"); + assert!(err.to_string().contains("failed to reload config")); + // After failure, git_support is reset and config falls back to built-in. + assert_eq!(state.git_support, GitSupport::Unknown); + assert!(!state.config.rule.is_empty()); + } + + // --------------------------------------------------------------------- + // handle_request / handle_notification with Connection::memory() + // --------------------------------------------------------------------- + + #[test] + fn handle_request_returns_method_not_found_for_unsupported_method() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let request = Request { + id: RequestId::from(1), + method: "totally/unsupported".to_string(), + params: json!({}), + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("error response"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, METHOD_NOT_FOUND); + assert!(error.message.contains("totally/unsupported")); + } + + #[test] + fn handle_code_action_request_rejects_invalid_params() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let request = Request { + id: RequestId::from(7), + method: CodeActionRequest::METHOD.to_string(), + params: json!({ "totally": "wrong" }), + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("error response"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, INVALID_PARAMS); + assert!(error.message.contains("invalid CodeActionParams")); + } + + #[test] + fn handle_execute_command_request_rejects_invalid_params() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let request = Request { + id: RequestId::from(2), + method: ExecuteCommand::METHOD.to_string(), + params: json!({ "nope": true }), + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("error response"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, INVALID_PARAMS); + assert!(error.message.contains("invalid ExecuteCommandParams")); + } + + #[test] + fn handle_execute_command_rejects_explain_rule_without_arg() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let params = ExecuteCommandParams { + command: CMD_EXPLAIN_RULE.to_string(), + arguments: vec![], + work_done_progress_params: Default::default(), + }; + let request = Request { + id: RequestId::from(3), + method: ExecuteCommand::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("error response"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, INVALID_PARAMS); + assert!(error.message.contains("missing rule ID")); + } + + #[test] + fn handle_execute_command_rejects_show_rule_url_without_url() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let params = ExecuteCommandParams { + command: CMD_SHOW_RULE_URL.to_string(), + arguments: vec![], + work_done_progress_params: Default::default(), + }; + let request = Request { + id: RequestId::from(4), + method: ExecuteCommand::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("error response"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, INVALID_PARAMS); + assert!(error.message.contains("missing URL")); + } + + #[test] + fn handle_execute_command_rejects_unknown_command() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let params = ExecuteCommandParams { + command: "diffguard.unknown".to_string(), + arguments: vec![], + work_done_progress_params: Default::default(), + }; + let request = Request { + id: RequestId::from(5), + method: ExecuteCommand::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("error response"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, INVALID_PARAMS); + assert!(error.message.contains("unsupported command")); + assert!(error.message.contains("diffguard.unknown")); + } + + #[test] + fn handle_execute_command_explain_rule_responds_with_found_payload() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + state.config = config_with_rules(vec![sample_rule("rust.no_unwrap", None)]); + let params = ExecuteCommandParams { + command: CMD_EXPLAIN_RULE.to_string(), + arguments: vec![json!("rust.no_unwrap")], + work_done_progress_params: Default::default(), + }; + let request = Request { + id: RequestId::from(6), + method: ExecuteCommand::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let result = response.result.as_ref().expect("result payload"); // diffguard: ignore rust.no_unwrap + assert_eq!(result.get("found").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + result.get("ruleId").and_then(|v| v.as_str()), + Some("rust.no_unwrap") + ); + // A showMessage notification accompanies the response. + assert!(find_notification(&messages, ShowMessage::METHOD).is_some()); + } + + #[test] + fn handle_execute_command_show_rule_url_uses_default_label_when_id_missing() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let params = ExecuteCommandParams { + command: CMD_SHOW_RULE_URL.to_string(), + arguments: vec![json!("https://example.com/docs")], + work_done_progress_params: Default::default(), + }; + let request = Request { + id: RequestId::from(8), + method: ExecuteCommand::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + handle_request(&server, &mut state, request).expect("handle ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response sent"); // diffguard: ignore rust.no_unwrap + let result = response.result.as_ref().expect("result payload"); // diffguard: ignore rust.no_unwrap + assert_eq!( + result.get("url").and_then(|v| v.as_str()), + Some("https://example.com/docs") + ); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("show message"); // diffguard: ignore rust.no_unwrap + let body = notif.params.to_string(); + assert!(body.contains("diffguard documentation")); + } + + #[test] + fn handle_notification_invalid_did_open_emits_warning() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: json!({ "garbage": true }), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("warning"); // diffguard: ignore rust.no_unwrap + assert!(notif.params.to_string().contains("invalid didOpen")); + } + + #[test] + fn handle_notification_invalid_did_change_emits_warning() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: DidChangeTextDocument::METHOD.to_string(), + params: json!({ "garbage": true }), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("warning"); // diffguard: ignore rust.no_unwrap + assert!(notif.params.to_string().contains("invalid didChange")); + } + + #[test] + fn handle_notification_invalid_did_save_emits_warning() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: DidSaveTextDocument::METHOD.to_string(), + params: json!({ "garbage": true }), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("warning"); // diffguard: ignore rust.no_unwrap + assert!(notif.params.to_string().contains("invalid didSave")); + } + + #[test] + fn handle_notification_invalid_did_close_emits_warning() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: DidCloseTextDocument::METHOD.to_string(), + params: json!({ "garbage": true }), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("warning"); // diffguard: ignore rust.no_unwrap + assert!(notif.params.to_string().contains("invalid didClose")); + } + + #[test] + fn handle_notification_invalid_did_change_configuration_emits_warning() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: DidChangeConfiguration::METHOD.to_string(), + params: json!("not-an-object"), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("warning"); // diffguard: ignore rust.no_unwrap + assert!( + notif + .params + .to_string() + .contains("invalid didChangeConfiguration") + ); + } + + #[test] + fn handle_notification_exit_signals_break_loop() { + let (_client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: Exit::METHOD.to_string(), + params: json!(null), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(exit); + } + + #[test] + fn handle_notification_unknown_method_is_silent_no_op() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let notification = Notification { + method: "made/up/method".to_string(), + params: json!({}), + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + assert!(messages.is_empty()); + } + + #[test] + fn handle_notification_did_close_clears_diagnostics() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let uri = parse_uri("file:///tmp/test.rs"); + state.documents.insert( + uri.clone(), + DocumentState::new(PathBuf::from("/tmp/test.rs"), 1, "fn x() {}\n".to_string()), + ); + let params = DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + }; + let notification = Notification { + method: DidCloseTextDocument::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + assert!(!state.documents.contains_key(&uri)); + let messages = drain_messages(&client); + // publishDiagnostics with empty list is emitted. + let publish = + find_notification(&messages, PublishDiagnostics::METHOD).expect("publish notif"); // diffguard: ignore rust.no_unwrap + let value = serde_json::to_value(&publish.params).expect("to value"); // diffguard: ignore rust.no_unwrap + let diags = value + .get("diagnostics") + .and_then(|d| d.as_array()) + .expect("diagnostics array"); // diffguard: ignore rust.no_unwrap + assert!(diags.is_empty()); + } + + #[test] + fn handle_notification_did_open_with_non_file_uri_skips_indexing() { + let (_client, server) = Connection::memory(); + let mut state = empty_state(); + let uri = parse_uri("https://example.com/foo.rs"); + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "rust".to_string(), + version: 1, + text: "fn x() {}\n".to_string(), + }, + }; + let notification = Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + // Document was not inserted because the URI couldn't be parsed as a file path. + assert!(state.documents.is_empty()); + } + + #[test] + fn handle_notification_did_change_for_unknown_document_is_ignored() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let uri = parse_uri("file:///tmp/never-opened.rs"); + let params = DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { uri, version: 2 }, + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "new".to_string(), + }], + }; + let notification = Notification { + method: DidChangeTextDocument::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + // No document was inserted, no diagnostics are published. + let messages = drain_messages(&client); + assert!(find_notification(&messages, PublishDiagnostics::METHOD).is_none()); + } + + #[test] + fn handle_notification_did_save_marks_document_saved() { + let (_client, server) = Connection::memory(); + let mut state = empty_state(); + let uri = parse_uri("file:///tmp/save.rs"); + state.documents.insert( + uri.clone(), + DocumentState::new(PathBuf::from("/tmp/save.rs"), 1, "first\n".to_string()), + ); + // Mutate the document so changed_lines is non-empty. + if let Some(doc) = state.documents.get_mut(&uri) { + let change = TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "second\n".to_string(), + }; + doc.apply_changes(&[change]).expect("apply ok"); // diffguard: ignore rust.no_unwrap + assert!(!doc.changed_lines.is_empty()); + } + + let params = DidSaveTextDocumentParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + text: Some("third\n".to_string()), + }; + let notification = Notification { + method: DidSaveTextDocument::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let doc = state.documents.get(&uri).expect("doc still present"); // diffguard: ignore rust.no_unwrap + assert_eq!(doc.text, "third\n"); + assert_eq!(doc.baseline_text, "third\n"); + assert!(doc.changed_lines.is_empty()); + } + + #[test] + fn handle_notification_did_change_configuration_triggers_reload_message() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let params = DidChangeConfigurationParams { + settings: json!({}), + }; + let notification = Notification { + method: DidChangeConfiguration::METHOD.to_string(), + params: serde_json::to_value(params).expect("serialize"), // diffguard: ignore rust.no_unwrap + }; + let exit = handle_notification(&server, &mut state, notification).expect("handle ok"); // diffguard: ignore rust.no_unwrap + assert!(!exit); + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("show message"); // diffguard: ignore rust.no_unwrap + // With no config_path set, reload succeeds and reports the rule count. + assert!(notif.params.to_string().contains("config reloaded")); + } + + #[test] + fn refresh_document_diagnostics_short_circuits_when_relative_path_empty() { + // With workspace_root set and the document's path equal to workspace_root, + // the stripped relative path is empty, which triggers the early-return + // branch that publishes an empty diagnostics list. + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let workspace = PathBuf::from("/tmp/empty-relative"); + state.workspace_root = Some(workspace.clone()); + let uri = parse_uri("file:///tmp/empty-relative"); + state + .documents + .insert(uri.clone(), DocumentState::new(workspace, 1, String::new())); + refresh_document_diagnostics(&server, &mut state, &uri).expect("refresh ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let publish = + find_notification(&messages, PublishDiagnostics::METHOD).expect("publish notif"); // diffguard: ignore rust.no_unwrap + let value = serde_json::to_value(&publish.params).expect("to value"); // diffguard: ignore rust.no_unwrap + let diags = value + .get("diagnostics") + .and_then(|d| d.as_array()) + .expect("diagnostics array"); // diffguard: ignore rust.no_unwrap + assert!(diags.is_empty()); + } + + #[test] + fn refresh_document_diagnostics_no_op_for_missing_document() { + let (client, server) = Connection::memory(); + let mut state = empty_state(); + let uri = parse_uri("file:///tmp/missing.rs"); + refresh_document_diagnostics(&server, &mut state, &uri).expect("refresh ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + assert!(messages.is_empty()); + } + + // --------------------------------------------------------------------- + // send_*_response / show_message helpers + // --------------------------------------------------------------------- + + #[test] + fn send_error_response_constructs_proper_response_object() { + let (client, server) = Connection::memory(); + send_error_response( + &server, + RequestId::from(99), + INVALID_PARAMS, + "bad".to_string(), + ) + .expect("send ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response"); // diffguard: ignore rust.no_unwrap + let error = response.error.as_ref().expect("has error"); // diffguard: ignore rust.no_unwrap + assert_eq!(error.code, INVALID_PARAMS); + assert_eq!(error.message, "bad"); + assert!(response.result.is_none()); + } + + #[test] + fn send_ok_response_constructs_proper_response_object() { + let (client, server) = Connection::memory(); + send_ok_response(&server, RequestId::from(101), json!({ "ok": true })).expect("send ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let response = find_response(&messages).expect("response"); // diffguard: ignore rust.no_unwrap + assert!(response.error.is_none()); + let result = response.result.as_ref().expect("has result"); // diffguard: ignore rust.no_unwrap + assert_eq!(result.get("ok").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn show_message_emits_show_message_notification() { + let (client, server) = Connection::memory(); + show_message(&server, MessageType::INFO, "hello").expect("send ok"); // diffguard: ignore rust.no_unwrap + let messages = drain_messages(&client); + let notif = find_notification(&messages, ShowMessage::METHOD).expect("show message"); // diffguard: ignore rust.no_unwrap + assert!(notif.params.to_string().contains("hello")); + } }