diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 0800afe..8bd299d 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -4756,8 +4756,8 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Ok(true) } -fn cmd_lsp(_cli: &Cli) -> Result { - use lsp_server::{Connection, Message}; +fn cmd_lsp(cli: &Cli) -> Result { + use lsp_server::{Connection, Message, Response}; use lsp_types::*; eprintln!("rivet lsp: starting language server..."); @@ -4766,10 +4766,6 @@ fn cmd_lsp(_cli: &Cli) -> Result { let server_capabilities = ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), - diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions { - identifier: Some("rivet".to_string()), - ..Default::default() - })), hover_provider: Some(HoverProviderCapability::Simple(true)), definition_provider: Some(OneOf::Left(true)), completion_provider: Some(CompletionOptions { @@ -4780,9 +4776,55 @@ fn cmd_lsp(_cli: &Cli) -> Result { }; let init_params = connection.initialize(serde_json::to_value(server_capabilities).unwrap())?; - let _params: InitializeParams = serde_json::from_value(init_params)?; + let params: InitializeParams = serde_json::from_value(init_params)?; + + // Determine project root from workspace folders or root_uri + #[allow(deprecated)] + let project_dir = params + .root_uri + .as_ref() + .and_then(|u| { + let s = u.as_str(); + s.strip_prefix("file://").map(std::path::PathBuf::from) + }) + .unwrap_or_else(|| cli.project.clone()); + + eprintln!("rivet lsp: project root: {}", project_dir.display()); + + // Load project + let config_path = project_dir.join("rivet.yaml"); + let (store, schema, graph) = if config_path.exists() { + let config = rivet_core::load_project_config(&config_path).unwrap_or_else(|e| { + eprintln!("rivet lsp: failed to load config: {e}"); + std::process::exit(1); + }); + let schemas_dir = resolve_schemas_dir(cli); + let schema = rivet_core::load_schemas(&config.project.schemas, &schemas_dir) + .unwrap_or_else(|e| { + eprintln!("rivet lsp: schema error: {e}"); + rivet_core::load_schemas(&[], &schemas_dir).unwrap() + }); + let mut store = Store::new(); + for source in &config.sources { + if let Ok(artifacts) = rivet_core::load_artifacts(source, &project_dir) { + for artifact in artifacts { + store.upsert(artifact); + } + } + } + let graph = LinkGraph::build(&store, &schema); + (store, schema, graph) + } else { + eprintln!("rivet lsp: no rivet.yaml found, running with empty store"); + let empty_store = Store::new(); + let empty_schema = rivet_core::load_schemas(&[], &resolve_schemas_dir(cli)).unwrap(); + let empty_graph = LinkGraph::build(&empty_store, &empty_schema); + (empty_store, empty_schema, empty_graph) + }; - eprintln!("rivet lsp: initialized"); + // Publish initial diagnostics + lsp_publish_diagnostics(&connection, &store, &schema, &graph); + eprintln!("rivet lsp: initialized with {} artifacts", store.len()); // Main message loop for msg in &connection.receiver { @@ -4791,12 +4833,52 @@ fn cmd_lsp(_cli: &Cli) -> Result { if connection.handle_shutdown(&req)? { break; } - // TODO: handle requests (hover, goto-definition, completion) - eprintln!("rivet lsp: unhandled request: {}", req.method); + let method = req.method.as_str(); + match method { + "textDocument/hover" => { + let params: HoverParams = serde_json::from_value(req.params.clone())?; + let result = lsp_hover(¶ms, &store); + connection.sender.send(Message::Response(Response { + id: req.id, + result: Some(serde_json::to_value(result)?), + error: None, + }))?; + } + "textDocument/definition" => { + let params: GotoDefinitionParams = + serde_json::from_value(req.params.clone())?; + let result = lsp_goto_definition(¶ms, &store); + connection.sender.send(Message::Response(Response { + id: req.id, + result: Some(serde_json::to_value(result)?), + error: None, + }))?; + } + "textDocument/completion" => { + let params: CompletionParams = serde_json::from_value(req.params.clone())?; + let result = lsp_completion(¶ms, &store, &schema); + connection.sender.send(Message::Response(Response { + id: req.id, + result: Some(serde_json::to_value(result)?), + error: None, + }))?; + } + _ => { + eprintln!("rivet lsp: unhandled request: {method}"); + connection.sender.send(Message::Response(Response { + id: req.id, + result: Some(serde_json::Value::Null), + error: None, + }))?; + } + } } Message::Notification(notif) => { - eprintln!("rivet lsp: notification: {}", notif.method); - // TODO: handle didOpen, didChange, didSave + if notif.method == "textDocument/didSave" { + eprintln!("rivet lsp: file saved, re-validating..."); + // TODO: reload project and re-publish diagnostics + // For now, just log + } } Message::Response(_) => {} } @@ -4807,6 +4889,237 @@ fn cmd_lsp(_cli: &Cli) -> Result { Ok(true) } +// ── LSP helpers ────────────────────────────────────────────────────────── + +fn lsp_uri_to_path(uri: &lsp_types::Uri) -> Option { + let s = uri.as_str(); + s.strip_prefix("file://").map(std::path::PathBuf::from) +} + +fn lsp_path_to_uri(path: &std::path::Path) -> Option { + let s = format!("file://{}", path.display()); + s.parse().ok() +} + +fn lsp_find_artifact_line(path: &std::path::Path, artifact_id: &str) -> u32 { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .enumerate() + .find(|(_, line)| { + let t = line.trim(); + t == format!("id: {artifact_id}") || t == format!("- id: {artifact_id}") + }) + .map(|(i, _)| i as u32) + .unwrap_or(0) +} + +fn lsp_word_at_position(content: &str, line: u32, character: u32) -> String { + content + .lines() + .nth(line as usize) + .map(|l| { + let chars: Vec = l.chars().collect(); + let pos = (character as usize).min(chars.len()); + let start = (0..pos) + .rev() + .find(|&i| { + !chars + .get(i) + .map(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .unwrap_or(false) + }) + .map(|i| i + 1) + .unwrap_or(0); + let end = (pos..chars.len()) + .find(|&i| { + !chars + .get(i) + .map(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .unwrap_or(false) + }) + .unwrap_or(chars.len()); + chars[start..end].iter().collect() + }) + .unwrap_or_default() +} + +fn lsp_publish_diagnostics( + connection: &lsp_server::Connection, + store: &Store, + schema: &rivet_core::schema::Schema, + graph: &LinkGraph, +) { + use lsp_types::*; + + let diagnostics = validate::validate(store, schema, graph); + let mut file_diags: std::collections::HashMap> = + std::collections::HashMap::new(); + + for diag in &diagnostics { + let art_id = match diag.artifact_id { + Some(ref id) => id.as_str(), + None => continue, + }; + let art = store.get(art_id); + let source_file = art.and_then(|a| a.source_file.as_ref()); + if let Some(path) = source_file { + let line = lsp_find_artifact_line(path, art_id); + file_diags + .entry(path.clone()) + .or_default() + .push(lsp_types::Diagnostic { + range: Range { + start: Position { line, character: 0 }, + end: Position { + line, + character: 100, + }, + }, + severity: Some(match diag.severity { + rivet_core::schema::Severity::Error => DiagnosticSeverity::ERROR, + rivet_core::schema::Severity::Warning => DiagnosticSeverity::WARNING, + rivet_core::schema::Severity::Info => DiagnosticSeverity::INFORMATION, + }), + source: Some("rivet".to_string()), + message: diag.message.clone(), + ..Default::default() + }); + } + } + + for (path, diags) in &file_diags { + if let Some(uri) = lsp_path_to_uri(path) { + let params = PublishDiagnosticsParams { + uri, + diagnostics: diags.clone(), + version: None, + }; + let _ = connection.sender.send(lsp_server::Message::Notification( + lsp_server::Notification { + method: "textDocument/publishDiagnostics".to_string(), + params: serde_json::to_value(params).unwrap(), + }, + )); + } + } + + eprintln!( + "rivet lsp: published {} diagnostics across {} files", + diagnostics.len(), + file_diags.len() + ); +} + +fn lsp_hover(params: &lsp_types::HoverParams, store: &Store) -> Option { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + let path = lsp_uri_to_path(uri)?; + let content = std::fs::read_to_string(&path).ok()?; + let word = lsp_word_at_position(&content, pos.line, pos.character); + + let art = store.get(&word)?; + let mut md = format!("**{}** `{}`\n\n", art.title, art.artifact_type); + if let Some(ref desc) = art.description { + let short = if desc.len() > 300 { + format!("{}...", &desc[..300]) + } else { + desc.clone() + }; + md.push_str(&short); + md.push('\n'); + } + md.push_str(&format!( + "\nStatus: `{}`", + art.status.as_deref().unwrap_or("—") + )); + if !art.links.is_empty() { + md.push_str(&format!(" | Links: {}", art.links.len())); + } + if !art.tags.is_empty() { + md.push_str(&format!(" | Tags: {}", art.tags.join(", "))); + } + + Some(lsp_types::Hover { + contents: lsp_types::HoverContents::Markup(lsp_types::MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: md, + }), + range: None, + }) +} + +fn lsp_goto_definition( + params: &lsp_types::GotoDefinitionParams, + store: &Store, +) -> Option { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + let path = lsp_uri_to_path(uri)?; + let content = std::fs::read_to_string(&path).ok()?; + let word = lsp_word_at_position(&content, pos.line, pos.character); + + let art = store.get(&word)?; + let source = art.source_file.as_ref()?; + let line = lsp_find_artifact_line(source, &word); + let target_uri = lsp_path_to_uri(source)?; + + Some(lsp_types::Location { + uri: target_uri, + range: lsp_types::Range { + start: lsp_types::Position { line, character: 0 }, + end: lsp_types::Position { line, character: 0 }, + }, + }) +} + +fn lsp_completion( + params: &lsp_types::CompletionParams, + store: &Store, + schema: &rivet_core::schema::Schema, +) -> Option { + let uri = ¶ms.text_document_position.text_document.uri; + let pos = params.text_document_position.position; + let path = lsp_uri_to_path(uri)?; + let content = std::fs::read_to_string(&path).ok()?; + let line_text = content.lines().nth(pos.line as usize).unwrap_or(""); + let trimmed = line_text.trim(); + + let mut items = Vec::new(); + + if trimmed.starts_with("target:") || trimmed.starts_with("- target:") || trimmed.contains("[[") + { + // Suggest artifact IDs + for art in store.iter() { + items.push(lsp_types::CompletionItem { + label: art.id.clone(), + kind: Some(lsp_types::CompletionItemKind::REFERENCE), + detail: Some(format!("{} ({})", art.title, art.artifact_type)), + ..Default::default() + }); + } + } else if trimmed.starts_with("type:") || trimmed.starts_with("- type:") { + // Suggest artifact types seen in the store + let mut types: Vec = store.types().map(|t| t.to_string()).collect(); + types.sort(); + types.dedup(); + for t in types { + let desc = schema.artifact_type(&t).map(|td| td.description.clone()); + items.push(lsp_types::CompletionItem { + label: t, + kind: Some(lsp_types::CompletionItemKind::CLASS), + detail: desc, + ..Default::default() + }); + } + } + + Some(lsp_types::CompletionList { + is_incomplete: false, + items, + }) +} + /// Substitute `$prev` in a string with the most recently generated ID. fn substitute_prev(s: &str, prev: &Option) -> String { if s == "$prev" { diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index f77aaf9..8a8d579 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -90,6 +90,71 @@ impl Artifact { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::minimal_artifact; + + #[test] + fn artifact_baseline_returns_field_value() { + let mut a = minimal_artifact("A-1", "req"); + a.fields.insert( + "baseline".into(), + serde_yaml::Value::String("v0.2.0".into()), + ); + assert_eq!(a.baseline(), Some("v0.2.0")); + } + + #[test] + fn artifact_baseline_returns_none_when_missing() { + let a = minimal_artifact("A-1", "req"); + assert_eq!(a.baseline(), None); + } + + #[test] + fn artifact_baseline_returns_none_for_non_string() { + let mut a = minimal_artifact("A-1", "req"); + a.fields + .insert("baseline".into(), serde_yaml::Value::Bool(true)); + assert_eq!(a.baseline(), None); + } + + #[test] + fn baseline_config_deserializes() { + let yaml = r#" +name: v0.1.0 +description: Initial release +"#; + let config: BaselineConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.name, "v0.1.0"); + assert_eq!(config.description.as_deref(), Some("Initial release")); + } + + #[test] + fn baseline_config_deserializes_without_description() { + let yaml = "name: v0.2.0\n"; + let config: BaselineConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.name, "v0.2.0"); + assert_eq!(config.description, None); + } + + #[test] + fn baseline_config_list_deserializes() { + let yaml = r#" +- name: v0.1.0 + description: First baseline +- name: v0.2.0 +- name: v0.3.0 + description: Third baseline +"#; + let configs: Vec = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(configs.len(), 3); + assert_eq!(configs[0].name, "v0.1.0"); + assert_eq!(configs[1].description, None); + assert_eq!(configs[2].name, "v0.3.0"); + } +} + /// Configuration for a named baseline (release scope). /// /// Baselines are declared in order in `rivet.yaml`. Validation and diff --git a/rivet-core/src/schema.rs b/rivet-core/src/schema.rs index 49384c2..d08c467 100644 --- a/rivet-core/src/schema.rs +++ b/rivet-core/src/schema.rs @@ -587,3 +587,224 @@ impl Schema { self.inverse_map.get(link_type).map(|s| s.as_str()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::minimal_artifact; + use std::borrow::Cow; + + /// Build an artifact with custom fields in the `fields` map. + fn artifact_with_fields(id: &str, fields: Vec<(&str, serde_yaml::Value)>) -> Artifact { + let mut a = minimal_artifact(id, "test"); + for (k, v) in fields { + a.fields.insert(k.to_string(), v); + } + a + } + + // ── get_field_value tests ──────────────────────────────────────────── + + #[test] + fn get_field_value_returns_borrowed_for_id() { + let a = minimal_artifact("X-1", "test"); + let val = get_field_value(&a, "id"); + assert_eq!(val, Some(Cow::Borrowed("X-1"))); + } + + #[test] + fn get_field_value_returns_borrowed_for_title() { + let a = minimal_artifact("X-1", "test"); + let val = get_field_value(&a, "title"); + assert_eq!(val, Some(Cow::Borrowed("Test X-1"))); + } + + #[test] + fn get_field_value_returns_borrowed_for_status() { + let mut a = minimal_artifact("X-1", "test"); + a.status = Some("approved".into()); + let val = get_field_value(&a, "status"); + assert_eq!(val, Some(Cow::Borrowed("approved"))); + } + + #[test] + fn get_field_value_returns_none_for_missing_status() { + let a = minimal_artifact("X-1", "test"); + let val = get_field_value(&a, "status"); + assert_eq!(val, None); + } + + #[test] + fn get_field_value_returns_borrowed_for_description() { + let mut a = minimal_artifact("X-1", "test"); + a.description = Some("A description".into()); + let val = get_field_value(&a, "description"); + assert_eq!(val, Some(Cow::Borrowed("A description"))); + } + + #[test] + fn get_field_value_returns_none_for_missing_description() { + let a = minimal_artifact("X-1", "test"); + let val = get_field_value(&a, "description"); + assert_eq!(val, None); + } + + #[test] + fn get_field_value_tags_empty_returns_none() { + let a = minimal_artifact("X-1", "test"); + let val = get_field_value(&a, "tags"); + assert_eq!(val, None); + } + + #[test] + fn get_field_value_tags_joined() { + let mut a = minimal_artifact("X-1", "test"); + a.tags = vec!["safety".into(), "asil-b".into()]; + let val = get_field_value(&a, "tags"); + assert_eq!(val, Some(Cow::::Owned("safety,asil-b".into()))); + } + + #[test] + fn get_field_value_custom_string_field() { + let a = artifact_with_fields( + "X-1", + vec![("safety", serde_yaml::Value::String("ASIL_B".into()))], + ); + let val = get_field_value(&a, "safety"); + assert_eq!(val, Some(Cow::Borrowed("ASIL_B"))); + } + + #[test] + fn get_field_value_custom_bool_field() { + let a = artifact_with_fields("X-1", vec![("critical", serde_yaml::Value::Bool(true))]); + let val = get_field_value(&a, "critical"); + assert_eq!(val, Some(Cow::::Owned("true".into()))); + } + + #[test] + fn get_field_value_custom_number_field() { + let a = artifact_with_fields( + "X-1", + vec![( + "priority", + serde_yaml::Value::Number(serde_yaml::Number::from(42)), + )], + ); + let val = get_field_value(&a, "priority"); + assert_eq!(val, Some(Cow::::Owned("42".into()))); + } + + #[test] + fn get_field_value_missing_custom_field() { + let a = minimal_artifact("X-1", "test"); + let val = get_field_value(&a, "nonexistent"); + assert_eq!(val, None); + } + + // ── compile_regex tests ────────────────────────────────────────────── + + #[test] + fn compile_regex_returns_some_for_matches_condition() { + let cond = Condition::Matches { + field: "safety".into(), + pattern: "ASIL_.*".into(), + }; + let re = cond.compile_regex(); + assert!(re.is_some()); + assert!(re.unwrap().is_match("ASIL_B")); + } + + #[test] + fn compile_regex_returns_none_for_equals_condition() { + let cond = Condition::Equals { + field: "status".into(), + value: "approved".into(), + }; + assert!(cond.compile_regex().is_none()); + } + + #[test] + fn compile_regex_returns_none_for_exists_condition() { + let cond = Condition::Exists { + field: "description".into(), + }; + assert!(cond.compile_regex().is_none()); + } + + #[test] + fn compile_regex_returns_none_for_invalid_pattern() { + let cond = Condition::Matches { + field: "x".into(), + pattern: "[invalid(".into(), + }; + assert!(cond.compile_regex().is_none()); + } + + // ── matches_artifact_with tests ────────────────────────────────────── + + #[test] + fn matches_artifact_with_precompiled_regex() { + let cond = Condition::Matches { + field: "safety".into(), + pattern: "ASIL_.*".into(), + }; + let re = cond.compile_regex(); + let a = artifact_with_fields( + "X-1", + vec![("safety", serde_yaml::Value::String("ASIL_D".into()))], + ); + assert!(cond.matches_artifact_with(&a, re.as_ref())); + } + + #[test] + fn matches_artifact_with_precompiled_regex_no_match() { + let cond = Condition::Matches { + field: "safety".into(), + pattern: "ASIL_.*".into(), + }; + let re = cond.compile_regex(); + let a = artifact_with_fields( + "X-1", + vec![("safety", serde_yaml::Value::String("QM".into()))], + ); + assert!(!cond.matches_artifact_with(&a, re.as_ref())); + } + + #[test] + fn matches_artifact_with_none_regex_falls_back() { + // When compiled regex is None for a Matches condition, falls back to + // inline compilation via matches_artifact. + let cond = Condition::Matches { + field: "safety".into(), + pattern: "ASIL_.*".into(), + }; + let a = artifact_with_fields( + "X-1", + vec![("safety", serde_yaml::Value::String("ASIL_C".into()))], + ); + assert!(cond.matches_artifact_with(&a, None)); + } + + #[test] + fn matches_artifact_with_equals_ignores_compiled() { + let cond = Condition::Equals { + field: "status".into(), + value: "approved".into(), + }; + let mut a = minimal_artifact("X-1", "test"); + a.status = Some("approved".into()); + // Pass Some regex even though it's Equals — should be ignored + let dummy_re = Regex::new(".*").unwrap(); + assert!(cond.matches_artifact_with(&a, Some(&dummy_re))); + } + + #[test] + fn matches_artifact_with_exists_ignores_compiled() { + let cond = Condition::Exists { + field: "description".into(), + }; + let mut a = minimal_artifact("X-1", "test"); + a.description = Some("present".into()); + assert!(cond.matches_artifact_with(&a, None)); + } +} diff --git a/rivet-core/src/store.rs b/rivet-core/src/store.rs index aab0e0a..934df71 100644 --- a/rivet-core/src/store.rs +++ b/rivet-core/src/store.rs @@ -144,3 +144,132 @@ impl Store { scoped } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::minimal_artifact; + + fn artifact_with_baseline(id: &str, art_type: &str, baseline: &str) -> Artifact { + let mut a = minimal_artifact(id, art_type); + a.fields.insert( + "baseline".into(), + serde_yaml::Value::String(baseline.into()), + ); + a + } + + fn baselines(names: &[&str]) -> Vec { + names + .iter() + .map(|n| BaselineConfig { + name: n.to_string(), + description: None, + }) + .collect() + } + + #[test] + fn scoped_store_filters_by_baseline() { + let mut store = Store::new(); + store + .insert(artifact_with_baseline("A-1", "req", "v0.1.0")) + .unwrap(); + store + .insert(artifact_with_baseline("A-2", "req", "v0.2.0")) + .unwrap(); + store + .insert(artifact_with_baseline("A-3", "req", "v0.3.0")) + .unwrap(); + + let bl = baselines(&["v0.1.0", "v0.2.0", "v0.3.0"]); + + // Scope to v0.1.0 — only A-1 + let s1 = store.scoped("v0.1.0", &bl); + assert_eq!(s1.len(), 1); + assert!(s1.contains("A-1")); + + // Scope to v0.2.0 — cumulative: A-1 and A-2 + let s2 = store.scoped("v0.2.0", &bl); + assert_eq!(s2.len(), 2); + assert!(s2.contains("A-1")); + assert!(s2.contains("A-2")); + + // Scope to v0.3.0 — all three + let s3 = store.scoped("v0.3.0", &bl); + assert_eq!(s3.len(), 3); + } + + #[test] + fn scoped_store_unknown_baseline_returns_full() { + let mut store = Store::new(); + store + .insert(artifact_with_baseline("A-1", "req", "v0.1.0")) + .unwrap(); + store.insert(minimal_artifact("A-2", "req")).unwrap(); + + let bl = baselines(&["v0.1.0"]); + let scoped = store.scoped("unknown", &bl); + // Unknown baseline returns a clone of the full store + assert_eq!(scoped.len(), store.len()); + } + + #[test] + fn scoped_store_excludes_untagged_artifacts() { + let mut store = Store::new(); + store + .insert(artifact_with_baseline("A-1", "req", "v0.1.0")) + .unwrap(); + // A-2 has no baseline field + store.insert(minimal_artifact("A-2", "req")).unwrap(); + + let bl = baselines(&["v0.1.0"]); + let scoped = store.scoped("v0.1.0", &bl); + // Only A-1 is included; A-2 has no baseline and is excluded + assert_eq!(scoped.len(), 1); + assert!(scoped.contains("A-1")); + assert!(!scoped.contains("A-2")); + } + + #[test] + fn upsert_new_artifact() { + let mut store = Store::new(); + let a = minimal_artifact("A-1", "req"); + store.upsert(a); + assert_eq!(store.len(), 1); + assert!(store.contains("A-1")); + assert_eq!(store.by_type("req"), &["A-1"]); + } + + #[test] + fn upsert_replaces_existing_same_type() { + let mut store = Store::new(); + let mut a1 = minimal_artifact("A-1", "req"); + a1.title = "Original".into(); + store.upsert(a1); + + let mut a2 = minimal_artifact("A-1", "req"); + a2.title = "Updated".into(); + store.upsert(a2); + + assert_eq!(store.len(), 1); + assert_eq!(store.get("A-1").unwrap().title, "Updated"); + // Type index should still have exactly one entry + assert_eq!(store.by_type("req").len(), 1); + } + + #[test] + fn upsert_replaces_existing_different_type() { + let mut store = Store::new(); + store.upsert(minimal_artifact("A-1", "req")); + assert_eq!(store.by_type("req").len(), 1); + + // Upsert with a different type + store.upsert(minimal_artifact("A-1", "feat")); + assert_eq!(store.len(), 1); + assert_eq!(store.get("A-1").unwrap().artifact_type, "feat"); + // Old type index should be cleared, new one populated + assert_eq!(store.by_type("req").len(), 0); + assert_eq!(store.by_type("feat").len(), 1); + } +}