terraphim_hooks/
replacement.rs

1//! Unified replacement service for hooks.
2
3use serde::{Deserialize, Serialize};
4use terraphim_automata::LinkType as AutomataLinkType;
5use terraphim_types::Thesaurus;
6use thiserror::Error;
7
8/// Re-export LinkType for convenience.
9pub use terraphim_automata::LinkType;
10
11/// Errors that can occur during replacement.
12#[derive(Error, Debug)]
13pub enum ReplacementError {
14    #[error("Automata error: {0}")]
15    Automata(#[from] terraphim_automata::TerraphimAutomataError),
16    #[error("UTF-8 conversion error: {0}")]
17    Utf8(#[from] std::string::FromUtf8Error),
18}
19
20/// Result of a replacement operation.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct HookResult {
23    /// The resulting text after replacement.
24    pub result: String,
25    /// The original input text.
26    pub original: String,
27    /// Number of replacements made.
28    pub replacements: usize,
29    /// Whether any changes were made.
30    pub changed: bool,
31    /// Error message if replacement failed (only set in fail-open mode).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub error: Option<String>,
34}
35
36impl HookResult {
37    /// Create a successful result.
38    pub fn success(original: String, result: String) -> Self {
39        let changed = original != result;
40        let replacements = if changed { 1 } else { 0 };
41        Self {
42            result,
43            original,
44            replacements,
45            changed,
46            error: None,
47        }
48    }
49
50    /// Create a pass-through result (no changes).
51    pub fn pass_through(original: String) -> Self {
52        Self {
53            result: original.clone(),
54            original,
55            replacements: 0,
56            changed: false,
57            error: None,
58        }
59    }
60
61    /// Create a fail-open result with error message.
62    pub fn fail_open(original: String, error: String) -> Self {
63        Self {
64            result: original.clone(),
65            original,
66            replacements: 0,
67            changed: false,
68            error: Some(error),
69        }
70    }
71}
72
73/// Unified replacement service using Terraphim knowledge graphs.
74pub struct ReplacementService {
75    thesaurus: Thesaurus,
76    link_type: AutomataLinkType,
77}
78
79impl ReplacementService {
80    /// Create a new replacement service with a thesaurus.
81    pub fn new(thesaurus: Thesaurus) -> Self {
82        Self {
83            thesaurus,
84            link_type: AutomataLinkType::PlainText,
85        }
86    }
87
88    /// Set the link type for replacements.
89    pub fn with_link_type(mut self, link_type: AutomataLinkType) -> Self {
90        self.link_type = link_type;
91        self
92    }
93
94    /// Perform replacement on text.
95    pub fn replace(&self, text: &str) -> Result<HookResult, ReplacementError> {
96        let result_bytes =
97            terraphim_automata::replace_matches(text, self.thesaurus.clone(), self.link_type)?;
98        let result = String::from_utf8(result_bytes)?;
99        Ok(HookResult::success(text.to_string(), result))
100    }
101
102    /// Perform replacement with fail-open semantics.
103    ///
104    /// If replacement fails, returns the original text unchanged with error in result.
105    pub fn replace_fail_open(&self, text: &str) -> HookResult {
106        match self.replace(text) {
107            Ok(result) => result,
108            Err(e) => HookResult::fail_open(text.to_string(), e.to_string()),
109        }
110    }
111
112    /// Find matches in text without replacing.
113    pub fn find_matches(
114        &self,
115        text: &str,
116    ) -> Result<Vec<terraphim_automata::Matched>, ReplacementError> {
117        Ok(terraphim_automata::find_matches(
118            text,
119            self.thesaurus.clone(),
120            true,
121        )?)
122    }
123
124    /// Check if text contains any terms from the thesaurus.
125    pub fn contains_matches(&self, text: &str) -> bool {
126        self.find_matches(text)
127            .map(|matches| !matches.is_empty())
128            .unwrap_or(false)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use terraphim_types::{NormalizedTerm, NormalizedTermValue};
136
137    fn create_test_thesaurus() -> Thesaurus {
138        let mut thesaurus = Thesaurus::new("test".to_string());
139
140        // Add npm -> bun mapping
141        let bun_term = NormalizedTerm::new(1, NormalizedTermValue::from("bun"));
142        thesaurus.insert(NormalizedTermValue::from("npm"), bun_term.clone());
143        thesaurus.insert(NormalizedTermValue::from("yarn"), bun_term.clone());
144        thesaurus.insert(NormalizedTermValue::from("pnpm"), bun_term);
145
146        thesaurus
147    }
148
149    #[test]
150    fn test_replacement_service_basic() {
151        let thesaurus = create_test_thesaurus();
152        let service = ReplacementService::new(thesaurus);
153
154        let result = service.replace("npm install").unwrap();
155        assert!(result.changed);
156        assert_eq!(result.result, "bun install");
157    }
158
159    #[test]
160    fn test_replacement_service_no_match() {
161        let thesaurus = create_test_thesaurus();
162        let service = ReplacementService::new(thesaurus);
163
164        let result = service.replace("cargo build").unwrap();
165        assert!(!result.changed);
166        assert_eq!(result.result, "cargo build");
167    }
168
169    #[test]
170    fn test_hook_result_success() {
171        let result = HookResult::success("npm".to_string(), "bun".to_string());
172        assert!(result.changed);
173        assert_eq!(result.replacements, 1);
174        assert!(result.error.is_none());
175    }
176
177    #[test]
178    fn test_hook_result_pass_through() {
179        let result = HookResult::pass_through("unchanged".to_string());
180        assert!(!result.changed);
181        assert_eq!(result.replacements, 0);
182        assert_eq!(result.result, result.original);
183    }
184
185    #[test]
186    fn test_hook_result_fail_open() {
187        let result = HookResult::fail_open("original".to_string(), "error msg".to_string());
188        assert!(!result.changed);
189        assert_eq!(result.result, "original");
190        assert_eq!(result.error, Some("error msg".to_string()));
191    }
192}