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