Skip to main content

difflore_core/domain/
models.rs

1use std::collections::HashMap;
2
3// ── Settings ──
4
5#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
6#[serde(rename_all = "camelCase")]
7pub struct ContextEngineRecord {
8    #[serde(default = "default_context_enabled")]
9    pub enabled: bool,
10    #[serde(default = "default_context_auto_retrieve")]
11    pub auto_retrieve: bool,
12    #[serde(default = "default_max_rule_results")]
13    pub max_rule_results: i32,
14    #[serde(default = "default_rule_token_budget")]
15    pub rule_token_budget: i32,
16    #[serde(default)]
17    pub allow_hosted_embeddings: bool,
18    // ── Semantic embedding provider (P3 MVP) ──
19    /// Master switch for the real (non-SHA1) embedding provider.
20    #[serde(default)]
21    pub semantic_embedding: bool,
22    /// OpenAI-compatible base URL, e.g. `https://api.openai.com/v1`.
23    #[serde(default)]
24    pub embedding_provider_url: Option<String>,
25    /// Keyring storage key for the provider's API key.
26    ///
27    /// This value is NOT the plaintext API key. It is an opaque identifier
28    /// (an AES-GCM ciphertext hex blob produced by `crypto::encrypt_secret`)
29    /// that is decrypted via `context::embedding::load_embedding_key` at
30    /// use time. The actual API key is protected by a master key stored in
31    /// the OS keyring.
32    #[serde(default)]
33    pub embedding_provider_key: Option<String>,
34    /// Model name, e.g. `text-embedding-3-small`.
35    #[serde(default)]
36    pub embedding_model: Option<String>,
37    /// Embedding dimension, e.g. 1536 for `text-embedding-3-small`.
38    #[serde(default)]
39    pub embedding_dim: Option<usize>,
40}
41
42const fn default_context_enabled() -> bool {
43    true
44}
45const fn default_context_auto_retrieve() -> bool {
46    true
47}
48const fn default_max_rule_results() -> i32 {
49    4
50}
51const fn default_rule_token_budget() -> i32 {
52    1500
53}
54
55impl Default for ContextEngineRecord {
56    fn default() -> Self {
57        Self {
58            enabled: true,
59            auto_retrieve: true,
60            max_rule_results: 4,
61            rule_token_budget: 1500,
62            allow_hosted_embeddings: false,
63            semantic_embedding: false,
64            embedding_provider_url: None,
65            embedding_provider_key: None,
66            embedding_model: None,
67            embedding_dim: None,
68        }
69    }
70}
71
72#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ReviewEngineRecord {
75    #[serde(default)]
76    pub multi_perspective: bool,
77    /// Phase 2 review-memory: recall past verdicts for similar code and
78    /// inject them into the review prompt. Defaults to `true` so upgrade
79    /// paths get the feature automatically; users can opt out via
80    /// settings without touching any other flag.
81    #[serde(default = "default_past_verdict_recall")]
82    pub past_verdict_recall: bool,
83    /// Phase 5.1 review-memory: run a second cheap-model "self-check"
84    /// pass over the merged issues to score confidence and drop obvious
85    /// false positives. Defaults to `true` so upgrade paths get the
86    /// feature automatically; users can opt out via settings.
87    #[serde(default = "default_true")]
88    pub self_check_enabled: bool,
89    /// Phase 5.3 review-memory: emit a one-line PR summary plus a
90    /// per-file walkthrough on each review. Defaults to `true` so
91    /// upgrade paths get the feature automatically.
92    #[serde(default = "default_true")]
93    pub review_summary_enabled: bool,
94    /// Phase 4 (review depth): snap each issue's reported line to the exact
95    /// new-file line range by matching against the parsed diff hunks
96    /// (hunk-aware resolution, ported from open-code-review), instead of
97    /// trusting the model's claimed diff line. Defaults to `true`: the
98    /// resolver only ever *sharpens* a line number — it returns `None` (and
99    /// the model's claimed line is kept) whenever no hunk confidently matches,
100    /// so this never regresses attribution, only tightens `difflore fix`
101    /// patch precision. Set to `false` to fall back to claimed lines only.
102    #[serde(default = "default_true")]
103    pub hunk_line_resolution: bool,
104    /// Review-depth: after rule recall, ask the review LLM provider, in one
105    /// extra batched call, whether each recalled rule's lesson actually
106    /// applies to THIS diff, then drop the rules it judges non-applicable
107    /// before they enter the review prompt. Higher-precision review with
108    /// fewer irrelevant injected rules, at the cost of one additional
109    /// latency-tolerant round-trip per review (only ever fired at review
110    /// time, never on the 800ms commit hook).
111    ///
112    /// Defaults to `false`. Unlike the other review-depth flags this is NOT
113    /// on-by-default: it adds a whole extra LLM call, the existing intent
114    /// rerank + strict file-pattern cascade already prune most off-topic
115    /// rules, and a flaky judge response must never silently starve a review
116    /// of its rules. Keeping it opt-in means the default review path is
117    /// byte-for-byte unchanged. Enabling it also widens the recalled
118    /// candidate pool (so the judge has more to filter from) — see
119    /// `judge_candidate_pool_top_k` in the review pipeline.
120    #[serde(default)]
121    pub rule_applicability_judge: bool,
122}
123
124const fn default_past_verdict_recall() -> bool {
125    true
126}
127const fn default_true() -> bool {
128    true
129}
130
131impl Default for ReviewEngineRecord {
132    fn default() -> Self {
133        Self {
134            multi_perspective: false,
135            past_verdict_recall: default_past_verdict_recall(),
136            self_check_enabled: default_true(),
137            review_summary_enabled: default_true(),
138            hunk_line_resolution: default_true(),
139            rule_applicability_judge: false,
140        }
141    }
142}
143
144/// Per-file intent / walkthrough entry emitted by Phase 5.3 review
145/// summary. `intent` is a short (one-sentence) human-readable
146/// description of what the file's diff is trying to accomplish.
147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct FileIntent {
150    pub file: String,
151    pub intent: String,
152}
153
154/// Phase 5.3 review summary: one-line PR description + per-file
155/// walkthrough + blocking / non-blocking issue counts. Attached to the
156/// top-level `ReviewCheckResult` as an `Option` so callers built before
157/// Phase 5.3 continue to see `None` and behave identically.
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
159#[serde(rename_all = "camelCase")]
160pub struct ReviewSummary {
161    pub one_line_summary: String,
162    pub walkthrough_by_file: Vec<FileIntent>,
163    pub blocking_count: u32,
164    pub non_blocking_count: u32,
165}
166
167#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct AppSettingsRecord {
170    #[serde(default)]
171    pub proxy_enabled: bool,
172    #[serde(default = "default_proxy_port")]
173    pub proxy_port: i32,
174    #[serde(default = "default_language")]
175    pub language: String,
176    #[serde(default = "default_theme")]
177    pub theme: String,
178    #[serde(default)]
179    pub sound_notifications: bool,
180    #[serde(default)]
181    pub default_shell: Option<String>,
182    #[serde(default = "default_workspace")]
183    pub default_workspace: String,
184    #[serde(default)]
185    pub shortcuts: HashMap<String, String>,
186    #[serde(default)]
187    pub context_engine: ContextEngineRecord,
188    #[serde(default)]
189    pub review_engine: ReviewEngineRecord,
190    /// Show the "install `DiffLore` into your agent" hint after local
191    /// commands when an agent is detected but the MCP server isn't wired
192    /// up. `true` = show (default), `false` = user has dismissed it.
193    #[serde(default = "default_true")]
194    pub hints_mcp: bool,
195
196    /// Default mode for `difflore fix` when no flag is given. One of
197    /// `preview | apply | ci`. Stored on disk as `fixDefaultMode`.
198    #[serde(default = "default_fix_default_mode", rename = "fixDefaultMode")]
199    pub fix_default_mode: String,
200
201    /// Whether `difflore cloud sync` should run automatically in the background
202    /// after login. Stored on disk as `syncAuto`.
203    #[serde(default, rename = "syncAuto")]
204    pub sync_auto: bool,
205
206    /// Whether commands that need cloud may auto-trigger a browser login
207    /// flow. `false` (default) keeps the CLI quiet on shared / headless
208    /// machines. Stored on disk as `cloudAutoLogin`.
209    #[serde(default, rename = "cloudAutoLogin")]
210    pub cloud_auto_login: bool,
211}
212
213const fn default_proxy_port() -> i32 {
214    4000
215}
216fn default_language() -> String {
217    "en".into()
218}
219fn default_theme() -> String {
220    "dark".into()
221}
222fn default_workspace() -> String {
223    "~/projects".into()
224}
225fn default_fix_default_mode() -> String {
226    "preview".into()
227}
228impl Default for AppSettingsRecord {
229    fn default() -> Self {
230        Self {
231            proxy_enabled: false,
232            proxy_port: default_proxy_port(),
233            language: default_language(),
234            theme: default_theme(),
235            sound_notifications: false,
236            default_shell: None,
237            default_workspace: default_workspace(),
238            shortcuts: HashMap::new(),
239            context_engine: ContextEngineRecord::default(),
240            review_engine: ReviewEngineRecord::default(),
241            hints_mcp: true,
242            fix_default_mode: default_fix_default_mode(),
243            sync_auto: false,
244            cloud_auto_login: false,
245        }
246    }
247}
248
249// ── Runtime Event ──
250
251#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
252pub struct RuntimeReadyEvent {
253    pub runtime: String,
254}
255
256// ── Projects ──
257
258#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct ProjectRecord {
261    pub id: String,
262    pub name: String,
263    pub path: String,
264    pub git_branch: Option<String>,
265    pub active_sessions: i32,
266    pub total_sessions: Option<i32>,
267    pub created_at: String,
268}
269
270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct AddProjectInput {
273    pub path: String,
274}
275
276#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct RemoveProjectInput {
279    pub id: String,
280}
281
282// ── Providers ──
283
284#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct ProviderRecord {
287    pub id: String,
288    pub name: String,
289    pub base_url: String,
290    pub api_key: Option<String>,
291    pub model_mapping: HashMap<String, String>,
292    pub is_active: bool,
293    pub created_at: String,
294    pub updated_at: String,
295}
296
297#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
298pub struct ProviderAddInput {
299    pub name: String,
300    pub base_url: String,
301    pub model_mapping: HashMap<String, String>,
302}
303
304#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
305pub struct ProviderUpdateInput {
306    pub id: String,
307    pub name: Option<String>,
308    pub base_url: Option<String>,
309    pub model_mapping: Option<HashMap<String, String>>,
310}
311
312#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
313pub struct ProviderRemoveInput {
314    pub id: String,
315}
316
317#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct ProviderSetActiveInput {
320    pub id: String,
321    pub is_active: bool,
322}
323
324// ── Skills ──
325
326#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct SkillRecord {
329    pub id: String,
330    pub name: String,
331    pub source: String,
332    pub directory: String,
333    pub version: String,
334    pub description: String,
335    pub r#type: String,
336    pub engines: Vec<String>,
337    pub tags: Vec<String>,
338    pub trigger: Option<String>,
339    pub check_prompt: Option<String>,
340    pub repo_owner: Option<String>,
341    pub repo_name: Option<String>,
342    pub repo_branch: Option<String>,
343    pub readme_url: Option<String>,
344    pub enabled_for_codex: bool,
345    pub enabled_for_claude: bool,
346    pub enabled_for_gemini: bool,
347    pub enabled_for_cursor: bool,
348    pub installed_at: String,
349    pub updated_at: String,
350    pub enforcement: Option<String>,
351    /// Input channel: `manual` | `conversation` | `pr_review` | `extracted`.
352    /// Conversation-channel rules get a lower base confidence (0.6 vs 0.7).
353    #[serde(default = "default_origin")]
354    pub origin: String,
355}
356
357fn default_origin() -> String {
358    "manual".to_owned()
359}
360
361#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
362pub struct InstallSkillInput {
363    pub owner: String,
364    pub repo: String,
365    pub branch: String,
366    pub directory: String,
367}
368
369#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
370pub struct RemoveSkillInput {
371    pub id: String,
372}
373
374#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
375pub struct ToggleSkillEngineInput {
376    pub id: String,
377    pub engine: String,
378    pub enabled: bool,
379}
380
381#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
382pub struct DiscoverSkillsInput {
383    pub owner: String,
384    pub repo: String,
385    pub branch: Option<String>,
386}
387
388#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
389#[serde(rename_all = "camelCase")]
390pub struct DiscoveredSkillRecord {
391    pub name: String,
392    pub description: String,
393    pub r#type: String,
394    pub engines: Vec<String>,
395    pub tags: Vec<String>,
396    pub version: String,
397    pub directory: String,
398    pub repo_owner: String,
399    pub repo_name: String,
400    pub repo_branch: String,
401    pub installed: bool,
402}
403
404#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
405pub struct CreateLocalSkillInput {
406    pub name: String,
407    pub engines: Option<Vec<String>>,
408    pub tags: Option<Vec<String>>,
409    pub description: Option<String>,
410    pub r#type: Option<String>,
411    pub trigger: Option<String>,
412    pub check_prompt: Option<String>,
413    pub content: Option<String>,
414}
415
416/// Input for `skills::remember()` — the 4th human-feedback channel.
417///
418/// Records a rule the user told an AI agent (or themselves via CLI) to
419/// remember during a coding conversation. Stored locally with
420/// `origin = 'conversation'`, base confidence 0.6 (slightly below `manual`
421/// since the agent transcribed free-text), and `published = false` so it
422/// stays on this device until the user explicitly publishes it.
423#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct RememberRuleInput {
426    /// Short rule title (becomes skill name and the H1 in SKILL.md).
427    pub title: String,
428    /// What the rule is and why — full natural-language body. The agent
429    /// should transcribe the user's own words; not summarise them away.
430    pub body: String,
431    /// Optional glob patterns the rule applies to (e.g. `["**/*.ts"]`).
432    /// Empty = universal rule. Drives strict file-pattern cascade.
433    #[serde(default)]
434    pub file_patterns: Option<Vec<String>>,
435    /// Optional bad-code snippet the user pointed at (the offending pattern).
436    pub bad_code: Option<String>,
437    /// Optional good-code snippet the user proposed (the corrected version).
438    pub good_code: Option<String>,
439    /// Optional severity hint surfaced in the rule body. `low|medium|high`.
440    pub severity: Option<String>,
441    /// Channel that recorded this — defaults to `conversation`. Tests and
442    /// the CLI override to `manual` so the discount + audit-tag behaviour
443    /// can be exercised explicitly.
444    #[serde(default)]
445    pub origin: Option<String>,
446}
447
448#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
449#[serde(rename_all = "camelCase")]
450pub struct SkillRepoRecord {
451    pub id: String,
452    pub owner: String,
453    pub name: String,
454    pub branch: String,
455    pub enabled: bool,
456    pub created_at: String,
457}
458
459#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
460pub struct SkillRepoAddInput {
461    pub owner: String,
462    pub name: String,
463    pub branch: Option<String>,
464}
465
466#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
467pub struct SkillRepoRemoveInput {
468    pub id: String,
469}
470
471// ── Confidence & Examples ──
472
473#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
474#[serde(rename_all = "camelCase")]
475pub struct UpdateConfidenceInput {
476    pub skill_id: String,
477    /// "accept" (+0.05) or "reject" (-0.1)
478    pub signal: String,
479}
480
481#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
482#[serde(rename_all = "camelCase")]
483pub struct AddExampleInput {
484    pub skill_id: String,
485    pub bad_code: String,
486    pub good_code: String,
487    pub description: Option<String>,
488    pub source: Option<String>,
489}
490
491#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct ListExamplesInput {
494    pub skill_id: String,
495}
496
497#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
498pub struct RemoveExampleInput {
499    pub id: String,
500}
501
502#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
503#[serde(rename_all = "camelCase")]
504pub struct RuleExampleRecord {
505    pub id: String,
506    pub skill_id: String,
507    pub bad_code: String,
508    pub good_code: String,
509    pub description: Option<String>,
510    pub source: String,
511    pub created_at: String,
512}
513
514// ── Git / Editor / Files ──
515
516#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
517#[serde(rename_all = "camelCase")]
518pub struct GitStatusInput {
519    pub project_path: String,
520}
521
522#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
523#[serde(rename_all = "camelCase")]
524pub struct GitStatusRecord {
525    pub branch: Option<String>,
526    pub ahead: i32,
527    pub behind: i32,
528    pub files: Vec<GitFileStatusRecord>,
529}
530
531#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub struct GitFileStatusRecord {
534    pub path: String,
535    pub status: String,
536    pub additions: i32,
537    pub deletions: i32,
538}
539
540#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
541#[serde(rename_all = "camelCase")]
542pub struct GitBranchesInput {
543    pub project_path: String,
544}
545
546#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
547#[serde(rename_all = "camelCase")]
548pub struct GitBranchRecord {
549    pub name: String,
550    pub current: bool,
551    pub remote: Option<String>,
552}
553
554#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
555#[serde(rename_all = "camelCase")]
556pub struct GitDiffInput {
557    pub project_path: String,
558    pub staged: Option<bool>,
559    pub ref1: Option<String>,
560    pub ref2: Option<String>,
561}
562
563#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
564#[serde(rename_all = "camelCase")]
565pub struct DiffHunkRecord {
566    pub header: String,
567    pub body: String,
568}
569
570#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
571#[serde(rename_all = "camelCase")]
572pub struct DiffContentRecord {
573    pub file_path: String,
574    pub hunks: Vec<DiffHunkRecord>,
575}
576
577#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct GitCommitInput {
580    pub project_path: String,
581    pub message: String,
582    /// Specific files to stage. If empty/None, stages all changes (`git add -A`).
583    pub files: Option<Vec<String>>,
584}
585
586#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct GitPushInput {
589    pub project_path: String,
590}
591
592#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct GitCreatePRInput {
595    pub project_path: String,
596    pub title: String,
597    pub body: Option<String>,
598    pub base: Option<String>,
599}
600
601#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct GitCheckoutPRInput {
604    pub project_path: String,
605    pub pr_number: Option<i32>,
606}
607
608#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
609#[serde(rename_all = "camelCase")]
610pub struct GitPRResult {
611    pub url: Option<String>,
612}
613
614#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
615#[serde(rename_all = "camelCase")]
616pub struct EditorOpenInput {
617    pub project_path: String,
618    pub editor: Option<String>,
619    pub file_path: Option<String>,
620    pub line: Option<u32>,
621}
622
623#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
624#[serde(rename_all = "camelCase")]
625pub struct FilesSearchInput {
626    pub project_path: String,
627    pub query: String,
628    pub limit: Option<i32>,
629}
630
631#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
632#[serde(rename_all = "camelCase")]
633pub struct FilesReadInput {
634    pub project_path: String,
635    pub relative_path: String,
636    pub start_line: Option<i32>,
637    pub end_line: Option<i32>,
638    pub max_bytes: Option<i32>,
639}
640
641#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
642#[serde(rename_all = "camelCase")]
643pub struct FileSearchResult {
644    pub path: String,
645    pub relative_path: String,
646    pub is_directory: bool,
647}
648
649#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
650#[serde(rename_all = "camelCase")]
651pub struct FileReadRecord {
652    pub absolute_path: String,
653    pub relative_path: String,
654    pub content: String,
655    pub language: Option<String>,
656    pub line_count: i32,
657    pub truncated: bool,
658    pub sha256: Option<String>,
659}
660
661impl crate::domain::rule_view::RuleView for SkillRecord {
662    fn id(&self) -> &str {
663        &self.id
664    }
665    fn content(&self) -> &str {
666        &self.description
667    }
668    fn origin(&self) -> &str {
669        &self.origin
670    }
671    fn confidence(&self) -> Option<f64> {
672        None
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use crate::domain::rule_view::RuleView;
680
681    #[test]
682    fn skill_record_implements_rule_view() {
683        let s = SkillRecord {
684            id: "id1".into(),
685            name: "n".into(),
686            source: "s".into(),
687            directory: "d".into(),
688            version: "0".into(),
689            description: "body".into(),
690            r#type: "review_standard".into(),
691            engines: vec![],
692            tags: vec![],
693            trigger: None,
694            check_prompt: None,
695            repo_owner: None,
696            repo_name: None,
697            repo_branch: None,
698            readme_url: None,
699            enabled_for_codex: false,
700            enabled_for_claude: false,
701            enabled_for_gemini: false,
702            enabled_for_cursor: false,
703            installed_at: String::new(),
704            updated_at: String::new(),
705            enforcement: None,
706            origin: "pr_review".into(),
707        };
708        assert_eq!(s.id(), "id1");
709        assert_eq!(s.content(), "body");
710        assert_eq!(s.origin(), "pr_review");
711        assert_eq!(s.confidence(), None);
712    }
713
714    #[test]
715    fn hunk_line_resolution_defaults_on() {
716        // Hunk-aware attribution must be ON by default so `difflore fix`
717        // patches anchor on the exact changed line, not a token-overlap guess.
718        assert!(ReviewEngineRecord::default().hunk_line_resolution);
719    }
720
721    #[test]
722    fn hunk_line_resolution_defaults_on_for_pre_phase4_configs() {
723        // Upgrade path: a persisted config that predates the flag (omits the
724        // key entirely) must deserialize with the feature ON via the serde
725        // default, not fall back to Rust's `bool::default()` (false).
726        let rec: ReviewEngineRecord = serde_json::from_str("{}").unwrap();
727        assert!(
728            rec.hunk_line_resolution,
729            "missing key must default to true (sharpens fix patches on upgrade)"
730        );
731        // And an explicit opt-out is still honoured.
732        let off: ReviewEngineRecord =
733            serde_json::from_str(r#"{"hunkLineResolution": false}"#).unwrap();
734        assert!(!off.hunk_line_resolution);
735    }
736
737    #[test]
738    fn rule_applicability_judge_defaults_off() {
739        // Opt-in feature: adds an extra LLM round-trip, so the default review
740        // path must stay byte-identical. Default + missing-key both = off.
741        assert!(!ReviewEngineRecord::default().rule_applicability_judge);
742        let rec: ReviewEngineRecord = serde_json::from_str("{}").unwrap();
743        assert!(
744            !rec.rule_applicability_judge,
745            "missing key must default to false (no extra LLM call unless opted in)"
746        );
747        // Explicit opt-in is honoured.
748        let on: ReviewEngineRecord =
749            serde_json::from_str(r#"{"ruleApplicabilityJudge": true}"#).unwrap();
750        assert!(on.rule_applicability_judge);
751    }
752}