Skip to main content

orcs_lua/
types.rs

1//! Type conversions between Rust and Lua.
2
3use mlua::{FromLua, IntoLua, Lua, Result as LuaResult, Value};
4use orcs_component::{EventCategory, Status};
5use orcs_event::{Request, Signal, SignalKind, SignalResponse};
6use serde::{Deserialize, Serialize};
7
8/// Request representation for Lua.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct LuaRequest {
11    /// Request ID.
12    pub id: String,
13    /// Operation name.
14    pub operation: String,
15    /// Category.
16    pub category: String,
17    /// Payload as JSON value.
18    pub payload: serde_json::Value,
19}
20
21impl LuaRequest {
22    /// Creates from orcs Request.
23    #[must_use]
24    pub fn from_request(request: &Request) -> Self {
25        Self {
26            id: request.id.to_string(),
27            operation: request.operation.clone(),
28            category: request.category.to_string(),
29            payload: request.payload.clone(),
30        }
31    }
32}
33
34impl IntoLua for LuaRequest {
35    fn into_lua(self, lua: &Lua) -> LuaResult<Value> {
36        let table = lua.create_table()?;
37        table.set("id", self.id)?;
38        table.set("operation", self.operation)?;
39        table.set("category", self.category)?;
40        // Convert payload to Lua value safely (no eval)
41        let payload = serde_json_to_lua(&self.payload, lua)?;
42        table.set("payload", payload)?;
43        Ok(Value::Table(table))
44    }
45}
46
47/// Signal representation for Lua.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct LuaSignal {
50    /// Signal kind.
51    pub kind: String,
52    /// Signal scope.
53    pub scope: String,
54    /// Optional target ID.
55    pub target_id: Option<String>,
56    /// Approval ID (for Approve/Reject/Modify signals).
57    pub approval_id: Option<String>,
58    /// Rejection reason (for Reject signals).
59    pub reason: Option<String>,
60}
61
62impl LuaSignal {
63    /// Creates from orcs Signal.
64    #[must_use]
65    pub fn from_signal(signal: &Signal) -> Self {
66        let (approval_id, reason) = match &signal.kind {
67            SignalKind::Approve { approval_id } => (Some(approval_id.clone()), None),
68            SignalKind::Reject {
69                approval_id,
70                reason,
71            } => (Some(approval_id.clone()), reason.clone()),
72            SignalKind::Modify { approval_id, .. } => (Some(approval_id.clone()), None),
73            _ => (None, None),
74        };
75
76        Self {
77            kind: signal_kind_to_string(&signal.kind),
78            scope: signal.scope.to_string(),
79            target_id: None,
80            approval_id,
81            reason,
82        }
83    }
84}
85
86/// Convert SignalKind to string.
87fn signal_kind_to_string(kind: &SignalKind) -> String {
88    match kind {
89        SignalKind::Veto => "Veto".to_string(),
90        SignalKind::Cancel => "Cancel".to_string(),
91        SignalKind::Pause => "Pause".to_string(),
92        SignalKind::Resume => "Resume".to_string(),
93        SignalKind::Steer { .. } => "Steer".to_string(),
94        SignalKind::Approve { .. } => "Approve".to_string(),
95        SignalKind::Reject { .. } => "Reject".to_string(),
96        SignalKind::Modify { .. } => "Modify".to_string(),
97    }
98}
99
100impl IntoLua for LuaSignal {
101    fn into_lua(self, lua: &Lua) -> LuaResult<Value> {
102        let table = lua.create_table()?;
103        table.set("kind", self.kind)?;
104        table.set("scope", self.scope)?;
105        if let Some(id) = self.target_id {
106            table.set("target_id", id)?;
107        }
108        if let Some(id) = self.approval_id {
109            table.set("approval_id", id)?;
110        }
111        if let Some(reason) = self.reason {
112            table.set("reason", reason)?;
113        }
114        Ok(Value::Table(table))
115    }
116}
117
118/// Response from Lua script.
119#[derive(Debug, Clone)]
120pub struct LuaResponse {
121    /// Success flag.
122    pub success: bool,
123    /// Response data (if success).
124    pub data: Option<serde_json::Value>,
125    /// Error message (if failed).
126    pub error: Option<String>,
127}
128
129impl FromLua for LuaResponse {
130    fn from_lua(value: Value, lua: &Lua) -> LuaResult<Self> {
131        match value {
132            Value::Table(table) => {
133                // Determine success flag.
134                // mlua converts Lua nil → Ok(false) for bool, so unwrap_or(true)
135                // never fires when the key is absent. Check the raw Value instead.
136                let success = match table.get::<Value>("success") {
137                    Ok(Value::Boolean(b)) => b,
138                    Ok(Value::Nil) => {
139                        // Key absent → infer from error field presence.
140                        table.get::<String>("error").is_err()
141                    }
142                    _ => true,
143                };
144                let data = table
145                    .get::<Value>("data")
146                    .ok()
147                    .and_then(|v| lua_to_json(v, lua).ok());
148                let error: Option<String> = table.get("error").ok();
149                Ok(Self {
150                    success,
151                    data,
152                    error,
153                })
154            }
155            Value::Nil => Ok(Self {
156                success: true,
157                data: None,
158                error: None,
159            }),
160            _ => {
161                // Treat any other value as data
162                let data = lua_to_json(value, lua).ok();
163                Ok(Self {
164                    success: true,
165                    data,
166                    error: None,
167                })
168            }
169        }
170    }
171}
172
173/// Parse SignalResponse from Lua string.
174pub fn parse_signal_response(s: &str) -> SignalResponse {
175    match s.to_lowercase().as_str() {
176        "handled" => SignalResponse::Handled,
177        "abort" => SignalResponse::Abort,
178        _ => SignalResponse::Ignored,
179    }
180}
181
182/// Parse EventCategory from Lua string.
183///
184/// Known categories are matched exactly (case-sensitive).
185/// Unknown strings fall back to `Extension { namespace: "lua", kind: s }`
186/// with a warning log, since this often indicates a typo.
187pub fn parse_event_category(s: &str) -> Option<EventCategory> {
188    match s {
189        "Lifecycle" => Some(EventCategory::Lifecycle),
190        "Hil" => Some(EventCategory::Hil),
191        "Echo" => Some(EventCategory::Echo),
192        "UserInput" => Some(EventCategory::UserInput),
193        "Output" => Some(EventCategory::Output),
194        _ => {
195            tracing::info!(
196                category = s,
197                "Lua subscription: treating custom category as Extension"
198            );
199            Some(EventCategory::Extension {
200                namespace: "lua".to_string(),
201                kind: s.to_string(),
202            })
203        }
204    }
205}
206
207/// Parse Status from Lua string.
208pub fn parse_status(s: &str) -> Status {
209    match s.to_lowercase().as_str() {
210        "initializing" => Status::Initializing,
211        "idle" => Status::Idle,
212        "running" => Status::Running,
213        "paused" => Status::Paused,
214        "awaitingapproval" | "awaiting_approval" => Status::AwaitingApproval,
215        "completed" => Status::Completed,
216        "error" => Status::Error,
217        "aborted" => Status::Aborted,
218        _ => Status::Idle,
219    }
220}
221
222/// Escape a string for safe inclusion in Lua code.
223///
224/// Handles all control characters and special sequences to prevent
225/// Lua code injection attacks.
226#[cfg(test)]
227fn escape_lua_string(s: &str) -> String {
228    let mut result = String::with_capacity(s.len() + 2);
229    result.push('"');
230    for c in s.chars() {
231        match c {
232            '\\' => result.push_str("\\\\"),
233            '"' => result.push_str("\\\""),
234            '\n' => result.push_str("\\n"),
235            '\r' => result.push_str("\\r"),
236            '\t' => result.push_str("\\t"),
237            '\0' => result.push_str("\\0"),
238            // Bell, backspace, form feed, vertical tab
239            '\x07' => result.push_str("\\a"),
240            '\x08' => result.push_str("\\b"),
241            '\x0C' => result.push_str("\\f"),
242            '\x0B' => result.push_str("\\v"),
243            // Other control characters (0x00-0x1F except already handled)
244            c if c.is_control() => {
245                result.push_str(&format!("\\{:03}", c as u32));
246            }
247            c => result.push(c),
248        }
249    }
250    result.push('"');
251    result
252}
253
254/// Escape a key for use in Lua table.
255#[cfg(test)]
256fn escape_lua_key(key: &str) -> String {
257    // Check if key is a valid Lua identifier
258    let is_valid_identifier = !key.is_empty()
259        && key
260            .chars()
261            .next()
262            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
263        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
264
265    if is_valid_identifier {
266        key.to_string()
267    } else {
268        format!("[{}]", escape_lua_string(key))
269    }
270}
271
272/// Convert JSON value to Lua string representation.
273///
274/// # Security
275///
276/// All string values are properly escaped to prevent Lua code injection.
277#[cfg(test)]
278pub fn json_to_lua(value: &serde_json::Value) -> String {
279    match value {
280        serde_json::Value::Null => "nil".to_string(),
281        serde_json::Value::Bool(b) => b.to_string(),
282        serde_json::Value::Number(n) => n.to_string(),
283        serde_json::Value::String(s) => escape_lua_string(s),
284        serde_json::Value::Array(arr) => {
285            let items: Vec<String> = arr.iter().map(json_to_lua).collect();
286            format!("{{{}}}", items.join(", "))
287        }
288        serde_json::Value::Object(obj) => {
289            let items: Vec<String> = obj
290                .iter()
291                .map(|(k, v)| format!("{} = {}", escape_lua_key(k), json_to_lua(v)))
292                .collect();
293            format!("{{{}}}", items.join(", "))
294        }
295    }
296}
297
298/// Convert serde_json::Value to Lua Value safely (no eval).
299pub fn serde_json_to_lua(value: &serde_json::Value, lua: &Lua) -> Result<Value, mlua::Error> {
300    match value {
301        serde_json::Value::Null => Ok(Value::Nil),
302        serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
303        serde_json::Value::Number(n) => {
304            if let Some(i) = n.as_i64() {
305                Ok(Value::Integer(i))
306            } else if let Some(f) = n.as_f64() {
307                Ok(Value::Number(f))
308            } else {
309                Err(mlua::Error::SerializeError("invalid number".into()))
310            }
311        }
312        serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
313        serde_json::Value::Array(arr) => {
314            let table = lua.create_table()?;
315            for (i, v) in arr.iter().enumerate() {
316                table.raw_set(i + 1, serde_json_to_lua(v, lua)?)?;
317            }
318            Ok(Value::Table(table))
319        }
320        serde_json::Value::Object(obj) => {
321            let table = lua.create_table()?;
322            for (k, v) in obj {
323                table.set(k.as_str(), serde_json_to_lua(v, lua)?)?;
324            }
325            Ok(Value::Table(table))
326        }
327    }
328}
329
330/// Convert Lua value to JSON.
331#[allow(clippy::only_used_in_recursion)]
332pub fn lua_to_json(value: Value, lua: &Lua) -> Result<serde_json::Value, mlua::Error> {
333    match value {
334        Value::Nil => Ok(serde_json::Value::Null),
335        Value::Boolean(b) => Ok(serde_json::Value::Bool(b)),
336        Value::Integer(i) => Ok(serde_json::Value::Number(i.into())),
337        Value::Number(n) => serde_json::Number::from_f64(n)
338            .map(serde_json::Value::Number)
339            .ok_or_else(|| mlua::Error::SerializeError("invalid number".into())),
340        Value::String(s) => Ok(serde_json::Value::String(s.to_string_lossy().to_string())),
341        Value::Table(table) => {
342            // Check if it's an array or object
343            let len = table.raw_len();
344            if len > 0 {
345                // Treat as array
346                let mut arr = Vec::new();
347                for i in 1..=len {
348                    let v: Value = table.raw_get(i)?;
349                    arr.push(lua_to_json(v, lua)?);
350                }
351                Ok(serde_json::Value::Array(arr))
352            } else {
353                // Treat as object
354                let mut map = serde_json::Map::new();
355                for pair in table.pairs::<String, Value>() {
356                    let (k, v) = pair?;
357                    map.insert(k, lua_to_json(v, lua)?);
358                }
359                Ok(serde_json::Value::Object(map))
360            }
361        }
362        _ => Err(mlua::Error::SerializeError("unsupported type".into())),
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn parse_signal_response_variants() {
372        assert!(matches!(
373            parse_signal_response("Handled"),
374            SignalResponse::Handled
375        ));
376        assert!(matches!(
377            parse_signal_response("Abort"),
378            SignalResponse::Abort
379        ));
380        assert!(matches!(
381            parse_signal_response("Ignored"),
382            SignalResponse::Ignored
383        ));
384        assert!(matches!(
385            parse_signal_response("unknown"),
386            SignalResponse::Ignored
387        ));
388    }
389
390    #[test]
391    fn parse_event_category_variants() {
392        assert_eq!(parse_event_category("Echo"), Some(EventCategory::Echo));
393        assert_eq!(parse_event_category("Hil"), Some(EventCategory::Hil));
394        // Unknown categories become Extension
395        assert!(matches!(
396            parse_event_category("Custom"),
397            Some(EventCategory::Extension { .. })
398        ));
399    }
400
401    #[test]
402    fn parse_status_variants() {
403        assert_eq!(parse_status("idle"), Status::Idle);
404        assert_eq!(parse_status("Running"), Status::Running);
405        assert_eq!(parse_status("aborted"), Status::Aborted);
406    }
407
408    #[test]
409    fn json_to_lua_conversion() {
410        assert_eq!(json_to_lua(&serde_json::json!(null)), "nil");
411        assert_eq!(json_to_lua(&serde_json::json!(true)), "true");
412        assert_eq!(json_to_lua(&serde_json::json!(42)), "42");
413        assert_eq!(json_to_lua(&serde_json::json!("hello")), "\"hello\"");
414    }
415
416    // === Security Tests for Lua Injection Prevention ===
417
418    #[test]
419    fn escape_lua_string_basic_escapes() {
420        // Backslash
421        assert_eq!(escape_lua_string(r"a\b"), r#""a\\b""#);
422        // Double quote
423        assert_eq!(escape_lua_string(r#"a"b"#), r#""a\"b""#);
424        // Newline
425        assert_eq!(escape_lua_string("a\nb"), r#""a\nb""#);
426        // Carriage return
427        assert_eq!(escape_lua_string("a\rb"), r#""a\rb""#);
428        // Tab
429        assert_eq!(escape_lua_string("a\tb"), r#""a\tb""#);
430        // Null
431        assert_eq!(escape_lua_string("a\0b"), r#""a\0b""#);
432    }
433
434    #[test]
435    fn escape_lua_string_control_chars() {
436        // Bell
437        assert_eq!(escape_lua_string("a\x07b"), r#""a\ab""#);
438        // Backspace
439        assert_eq!(escape_lua_string("a\x08b"), r#""a\bb""#);
440        // Form feed
441        assert_eq!(escape_lua_string("a\x0Cb"), r#""a\fb""#);
442        // Vertical tab
443        assert_eq!(escape_lua_string("a\x0Bb"), r#""a\vb""#);
444    }
445
446    #[test]
447    fn escape_lua_string_injection_attempt() {
448        // Attempt to break out of string and execute code
449        let malicious = "hello\nend; os.execute('rm -rf /')--";
450        let escaped = escape_lua_string(malicious);
451        // Should be safely escaped, not executable
452        assert_eq!(escaped, r#""hello\nend; os.execute('rm -rf /')--""#);
453
454        // Verify it doesn't contain unescaped newline
455        assert!(!escaped.contains('\n'));
456    }
457
458    #[test]
459    fn escape_lua_string_unicode() {
460        // Unicode should pass through unchanged
461        assert_eq!(escape_lua_string("日本語"), "\"日本語\"");
462        assert_eq!(escape_lua_string("emoji: 🎉"), "\"emoji: 🎉\"");
463    }
464
465    #[test]
466    fn escape_lua_string_empty() {
467        assert_eq!(escape_lua_string(""), "\"\"");
468    }
469
470    #[test]
471    fn escape_lua_key_valid_identifiers() {
472        assert_eq!(escape_lua_key("foo"), "foo");
473        assert_eq!(escape_lua_key("_bar"), "_bar");
474        assert_eq!(escape_lua_key("baz123"), "baz123");
475        assert_eq!(escape_lua_key("a_b_c"), "a_b_c");
476    }
477
478    #[test]
479    fn escape_lua_key_invalid_identifiers() {
480        // Starts with number
481        assert_eq!(escape_lua_key("123abc"), "[\"123abc\"]");
482        // Contains special chars
483        assert_eq!(escape_lua_key("foo-bar"), "[\"foo-bar\"]");
484        // Contains space
485        assert_eq!(escape_lua_key("foo bar"), "[\"foo bar\"]");
486        // Empty key
487        assert_eq!(escape_lua_key(""), "[\"\"]");
488    }
489
490    #[test]
491    fn json_to_lua_nested_structure() {
492        let nested = serde_json::json!({
493            "level1": {
494                "level2": {
495                    "value": "deep"
496                }
497            }
498        });
499        let lua_str = json_to_lua(&nested);
500        assert!(lua_str.contains("level1"));
501        assert!(lua_str.contains("level2"));
502        assert!(lua_str.contains("\"deep\""));
503    }
504
505    #[test]
506    fn json_to_lua_array() {
507        let arr = serde_json::json!([1, 2, "three", null]);
508        let lua_str = json_to_lua(&arr);
509        assert_eq!(lua_str, "{1, 2, \"three\", nil}");
510    }
511
512    #[test]
513    fn json_to_lua_special_string_in_object() {
514        let obj = serde_json::json!({
515            "key": "value\nwith\nnewlines"
516        });
517        let lua_str = json_to_lua(&obj);
518        // Newlines should be escaped
519        assert!(!lua_str.contains('\n'));
520        assert!(lua_str.contains(r"\n"));
521    }
522
523    #[test]
524    fn json_to_lua_execution_safety() {
525        // Create a Lua instance and verify the escaped string is safe
526        let lua = Lua::new();
527
528        // Malicious payload that tries to break out
529        let payload = serde_json::json!({
530            "data": "test\"); os.execute(\"echo pwned"
531        });
532        let lua_code = format!("return {}", json_to_lua(&payload));
533
534        // Should parse safely without executing os.execute
535        let result: mlua::Result<mlua::Table> = lua.load(&lua_code).eval();
536        assert!(result.is_ok(), "Lua code should parse safely");
537
538        // Verify the data is intact (escaped)
539        let table = result.expect("should parse safely without executing injected code");
540        let data: String = table
541            .get("data")
542            .expect("should have data field in safe table");
543        assert!(data.contains("os.execute"));
544    }
545
546    // === Boundary Tests for lua_to_json ===
547
548    #[test]
549    fn lua_to_json_empty_table() {
550        let lua = Lua::new();
551        let table = lua.create_table().expect("should create empty lua table");
552        let result =
553            lua_to_json(Value::Table(table), &lua).expect("should convert empty table to json");
554        assert_eq!(result, serde_json::json!({}));
555    }
556
557    #[test]
558    fn lua_to_json_array_table() {
559        let lua = Lua::new();
560        let table = lua.create_table().expect("should create array lua table");
561        table
562            .raw_set(1, "a")
563            .expect("should set index 1 in array table");
564        table
565            .raw_set(2, "b")
566            .expect("should set index 2 in array table");
567        let result =
568            lua_to_json(Value::Table(table), &lua).expect("should convert array table to json");
569        assert_eq!(result, serde_json::json!(["a", "b"]));
570    }
571
572    // === LuaResponse tests ===
573
574    #[test]
575    fn lua_response_explicit_success() {
576        let lua = Lua::new();
577        let table = lua.create_table().expect("create table");
578        table.set("success", true).expect("set success");
579        table
580            .set("data", lua.create_table().expect("inner table"))
581            .expect("set data");
582        let resp = LuaResponse::from_lua(Value::Table(table), &lua).expect("parse response");
583        assert!(resp.success);
584        assert!(resp.data.is_some());
585        assert!(resp.error.is_none());
586    }
587
588    #[test]
589    fn lua_response_explicit_failure() {
590        let lua = Lua::new();
591        let table = lua.create_table().expect("create table");
592        table.set("success", false).expect("set success");
593        table.set("error", "boom").expect("set error");
594        let resp = LuaResponse::from_lua(Value::Table(table), &lua).expect("parse response");
595        assert!(!resp.success);
596        assert_eq!(resp.error.as_deref(), Some("boom"));
597    }
598
599    #[test]
600    fn lua_response_missing_success_with_error_infers_false() {
601        // Worker returns { error = "...", source = "..." } without success field.
602        // Previously: nil→false via mlua bool conversion made this accidentally work,
603        // but the `unwrap_or(true)` was unreachable. Now: inferred from error field.
604        let lua = Lua::new();
605        let table = lua.create_table().expect("create table");
606        table.set("error", "llm call failed").expect("set error");
607        table.set("source", "common-agent").expect("set source");
608        let resp = LuaResponse::from_lua(Value::Table(table), &lua).expect("parse response");
609        assert!(
610            !resp.success,
611            "should infer failure from error field presence"
612        );
613        assert_eq!(resp.error.as_deref(), Some("llm call failed"));
614    }
615
616    #[test]
617    fn lua_response_missing_success_without_error_infers_true() {
618        // Worker returns { response = "...", source = "..." } without success field.
619        let lua = Lua::new();
620        let table = lua.create_table().expect("create table");
621        table.set("response", "hello").expect("set response");
622        table.set("source", "common-agent").expect("set source");
623        let resp = LuaResponse::from_lua(Value::Table(table), &lua).expect("parse response");
624        assert!(resp.success, "should infer success when no error field");
625    }
626
627    #[test]
628    fn lua_response_nil_is_success() {
629        let lua = Lua::new();
630        let resp = LuaResponse::from_lua(Value::Nil, &lua).expect("parse nil response");
631        assert!(resp.success);
632        assert!(resp.data.is_none());
633    }
634
635    #[test]
636    fn lua_to_json_mixed_numbers() {
637        let lua = Lua::new();
638
639        // Integer
640        let int_result = lua_to_json(Value::Integer(42), &lua).expect("integer conversion");
641        assert_eq!(int_result, serde_json::json!(42));
642
643        // Float
644        let float_result = lua_to_json(Value::Number(2.72), &lua).expect("float conversion");
645        assert!(float_result.as_f64().expect("f64") - 2.72 < 0.001);
646    }
647
648    #[test]
649    fn lua_to_json_valid_utf8_string() {
650        let lua = Lua::new();
651        let s = lua
652            .create_string("こんにちは世界")
653            .expect("create JP string");
654        let result =
655            lua_to_json(Value::String(s), &lua).expect("valid UTF-8 should convert successfully");
656        assert_eq!(result, serde_json::json!("こんにちは世界"));
657    }
658
659    #[test]
660    fn lua_to_json_invalid_utf8_string_uses_lossy() {
661        let lua = Lua::new();
662        // Build a byte sequence with invalid UTF-8: valid prefix + broken continuation
663        // "abc" (3 bytes) + 0xE3 0x81 (incomplete 3-byte sequence, missing last byte)
664        let bytes: &[u8] = &[0x61, 0x62, 0x63, 0xE3, 0x81];
665        let s = lua.create_string(bytes).expect("create binary string");
666        let result = lua_to_json(Value::String(s), &lua)
667            .expect("invalid UTF-8 should not error; lossy conversion instead");
668        let text = result.as_str().expect("should be a string");
669        assert!(
670            text.starts_with("abc"),
671            "valid prefix preserved, got: {text}"
672        );
673        assert!(
674            text.contains('\u{FFFD}'),
675            "replacement char expected for broken bytes, got: {text}"
676        );
677    }
678
679    #[test]
680    fn lua_to_json_truncated_multibyte_in_table() {
681        let lua = Lua::new();
682        // Simulate what agent_mgr does: a table with a truncated JP string
683        // "あ" = 0xE3 0x81 0x82, truncated to 2 bytes → invalid
684        let broken: &[u8] = &[0xE3, 0x81];
685        let table = lua.create_table().expect("create table");
686        table
687            .set("message", lua.create_string(broken).expect("broken str"))
688            .expect("set message");
689        let result = lua_to_json(Value::Table(table), &lua)
690            .expect("table with invalid UTF-8 string should convert via lossy");
691        let msg = result
692            .get("message")
693            .expect("message key")
694            .as_str()
695            .expect("string value");
696        assert!(
697            msg.contains('\u{FFFD}'),
698            "replacement char expected, got: {msg}"
699        );
700    }
701
702    // === utf8_truncate Lua function tests ===
703    // Tests the Lua-side helper from agent_mgr.lua that prevents
704    // string.sub from splitting multi-byte UTF-8 characters.
705
706    const UTF8_TRUNCATE_LUA: &str = r#"
707        local function utf8_truncate(s, max_bytes)
708            if #s <= max_bytes then return s end
709            local pos = max_bytes
710            while pos > 0 do
711                local b = string.byte(s, pos)
712                if b < 0x80 or b >= 0xC0 then break end
713                pos = pos - 1
714            end
715            if pos > 0 then
716                local b = string.byte(s, pos)
717                local char_len = 1
718                if     b >= 0xF0 then char_len = 4
719                elseif b >= 0xE0 then char_len = 3
720                elseif b >= 0xC0 then char_len = 2
721                end
722                if pos + char_len - 1 > max_bytes then
723                    return s:sub(1, pos - 1)
724                end
725                return s:sub(1, pos + char_len - 1)
726            end
727            return ""
728        end
729        return utf8_truncate
730    "#;
731
732    fn load_utf8_truncate(lua: &Lua) -> mlua::Function {
733        lua.load(UTF8_TRUNCATE_LUA)
734            .eval::<mlua::Function>()
735            .expect("load utf8_truncate")
736    }
737
738    #[test]
739    fn utf8_truncate_ascii_within_limit() {
740        let lua = Lua::new();
741        let f = load_utf8_truncate(&lua);
742        let result: String = f.call(("hello", 10)).expect("call");
743        assert_eq!(result, "hello");
744    }
745
746    #[test]
747    fn utf8_truncate_ascii_exact_limit() {
748        let lua = Lua::new();
749        let f = load_utf8_truncate(&lua);
750        let result: String = f.call(("hello", 5)).expect("call");
751        assert_eq!(result, "hello");
752    }
753
754    #[test]
755    fn utf8_truncate_ascii_over_limit() {
756        let lua = Lua::new();
757        let f = load_utf8_truncate(&lua);
758        let result: String = f.call(("hello world", 5)).expect("call");
759        assert_eq!(result, "hello");
760    }
761
762    #[test]
763    fn utf8_truncate_jp_preserves_full_chars() {
764        let lua = Lua::new();
765        let f = load_utf8_truncate(&lua);
766        // "あいう" = 9 bytes (3 chars × 3 bytes)
767        // Limit 7 → should keep "あい" (6 bytes), not split "う"
768        let result: String = f.call(("あいう", 7)).expect("call");
769        assert_eq!(result, "あい");
770    }
771
772    #[test]
773    fn utf8_truncate_jp_exact_boundary() {
774        let lua = Lua::new();
775        let f = load_utf8_truncate(&lua);
776        // "あいう" = 9 bytes; limit 9 → return full string
777        let result: String = f.call(("あいう", 9)).expect("call");
778        assert_eq!(result, "あいう");
779    }
780
781    #[test]
782    fn utf8_truncate_mixed_ascii_jp() {
783        let lua = Lua::new();
784        let f = load_utf8_truncate(&lua);
785        // "abcあ" = 3 + 3 = 6 bytes; limit 5 → "abc" (can't fit "あ")
786        let result: String = f.call(("abcあ", 5)).expect("call");
787        assert_eq!(result, "abc");
788    }
789
790    #[test]
791    fn utf8_truncate_4byte_emoji() {
792        let lua = Lua::new();
793        let f = load_utf8_truncate(&lua);
794        // "a😀b" = 1 + 4 + 1 = 6 bytes; limit 3 → "a" (can't fit 😀)
795        let result: String = f.call(("a😀b", 3)).expect("call");
796        assert_eq!(result, "a");
797    }
798
799    #[test]
800    fn utf8_truncate_result_is_valid_utf8() {
801        let lua = Lua::new();
802        let f = load_utf8_truncate(&lua);
803        // "日本語テスト" = 18 bytes; various cut points should all produce valid UTF-8
804        let input = "日本語テスト";
805        for limit in 1..=18 {
806            let result: String = f.call((input, limit)).expect("call");
807            // If we got here without error, it's valid UTF-8
808            assert!(
809                result.len() <= limit,
810                "limit={limit}, got {} bytes: {result}",
811                result.len()
812            );
813        }
814    }
815}