agentmux_srv\backend/
rpc_fileutil.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Wave file utility: conversion between WaveFile (storage) and FileInfo (RPC).
6//! Port of Go's pkg/util/rpc_fileutil/rpc_fileutil.go.
7//!
8//! Provides the bridge between the internal file storage representation
9//! and the wire format used by the RPC protocol.
10
11
12use serde::{Deserialize, Serialize};
13
14use super::storage::filestore::{FileMeta, FileOpts, WaveFile};
15
16/// URL pattern for wave file paths.
17pub const FILE_PATH_PATTERN: &str = "wavefile://";
18
19/// File information as exposed over the RPC wire.
20/// Matches Go's `wshrpc.FileInfo`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileInfo {
23    /// Full path (e.g., `wavefile://zone-id/filename`).
24    pub path: String,
25
26    /// File name (last component of path).
27    pub name: String,
28
29    /// File size in bytes.
30    pub size: i64,
31
32    /// Modification timestamp (milliseconds since epoch).
33    #[serde(default, skip_serializing_if = "is_zero")]
34    pub modts: i64,
35
36    /// Whether this is a directory.
37    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
38    pub isdir: bool,
39
40    /// MIME type (if known).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub mimetype: Option<String>,
43
44    /// File options.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub opts: Option<FileOpts>,
47
48    /// File metadata.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub meta: Option<FileMeta>,
51
52    /// Whether this is a readable file.
53    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
54    pub readonly: bool,
55}
56
57fn is_zero(v: &i64) -> bool {
58    *v == 0
59}
60
61/// Convert a WaveFile to FileInfo for RPC transmission.
62pub fn wave_file_to_file_info(file: &WaveFile) -> FileInfo {
63    let path = format!(
64        "{}{}/{}",
65        FILE_PATH_PATTERN, file.zoneid, file.name
66    );
67    FileInfo {
68        path,
69        name: file.name.clone(),
70        size: file.size,
71        modts: file.modts,
72        isdir: false,
73        mimetype: None,
74        opts: Some(file.opts.clone()),
75        meta: if file.meta.is_empty() {
76            None
77        } else {
78            Some(file.meta.clone())
79        },
80        readonly: false,
81    }
82}
83
84/// Convert a list of WaveFiles to FileInfo list.
85pub fn wave_file_list_to_file_info_list(files: &[WaveFile]) -> Vec<FileInfo> {
86    files.iter().map(wave_file_to_file_info).collect()
87}
88
89/// Parse a wave file path into (zone_id, file_name).
90/// Expects format: `wavefile://zone-id/filename`
91pub fn parse_wave_file_path(path: &str) -> Option<(String, String)> {
92    let rest = path.strip_prefix(FILE_PATH_PATTERN)?;
93    let slash_pos = rest.find('/')?;
94    let zone_id = &rest[..slash_pos];
95    let name = &rest[slash_pos + 1..];
96    if zone_id.is_empty() || name.is_empty() {
97        return None;
98    }
99    Some((zone_id.to_string(), name.to_string()))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::collections::HashMap;
106
107    fn make_test_file() -> WaveFile {
108        WaveFile {
109            zoneid: "zone-abc".to_string(),
110            name: "test.txt".to_string(),
111            size: 1024,
112            createdts: 1700000000000,
113            modts: 1700000001000,
114            opts: FileOpts::default(),
115            meta: HashMap::new(),
116        }
117    }
118
119    #[test]
120    fn test_wave_file_to_file_info() {
121        let file = make_test_file();
122        let info = wave_file_to_file_info(&file);
123        assert_eq!(info.path, "wavefile://zone-abc/test.txt");
124        assert_eq!(info.name, "test.txt");
125        assert_eq!(info.size, 1024);
126        assert_eq!(info.modts, 1700000001000);
127        assert!(!info.isdir);
128        assert!(!info.readonly);
129        assert!(info.meta.is_none()); // empty map → None
130    }
131
132    #[test]
133    fn test_wave_file_to_file_info_with_meta() {
134        let mut file = make_test_file();
135        file.meta
136            .insert("custom".to_string(), serde_json::json!("value"));
137        let info = wave_file_to_file_info(&file);
138        assert!(info.meta.is_some());
139        assert_eq!(
140            info.meta.unwrap().get("custom"),
141            Some(&serde_json::json!("value"))
142        );
143    }
144
145    #[test]
146    fn test_wave_file_to_file_info_with_opts() {
147        let mut file = make_test_file();
148        file.opts.circular = true;
149        file.opts.maxsize = 65536;
150        let info = wave_file_to_file_info(&file);
151        let opts = info.opts.unwrap();
152        assert!(opts.circular);
153        assert_eq!(opts.maxsize, 65536);
154    }
155
156    #[test]
157    fn test_wave_file_list_to_file_info_list() {
158        let files = vec![make_test_file(), make_test_file()];
159        let infos = wave_file_list_to_file_info_list(&files);
160        assert_eq!(infos.len(), 2);
161    }
162
163    #[test]
164    fn test_wave_file_list_empty() {
165        let infos = wave_file_list_to_file_info_list(&[]);
166        assert!(infos.is_empty());
167    }
168
169    #[test]
170    fn test_file_info_serialization() {
171        let info = FileInfo {
172            path: "wavefile://z1/f1".to_string(),
173            name: "f1".to_string(),
174            size: 100,
175            modts: 0,
176            isdir: false,
177            mimetype: None,
178            opts: None,
179            meta: None,
180            readonly: false,
181        };
182        let json = serde_json::to_string(&info).unwrap();
183        // Zero modts should be skipped
184        assert!(!json.contains("modts"));
185        // False isdir should be skipped
186        assert!(!json.contains("isdir"));
187        // None opts should be skipped
188        assert!(!json.contains("opts"));
189    }
190
191    #[test]
192    fn test_file_info_deserialization() {
193        let json = r#"{"path":"wavefile://z/f","name":"f","size":50,"modts":123,"isdir":false}"#;
194        let info: FileInfo = serde_json::from_str(json).unwrap();
195        assert_eq!(info.path, "wavefile://z/f");
196        assert_eq!(info.size, 50);
197        assert_eq!(info.modts, 123);
198    }
199
200    #[test]
201    fn test_parse_wave_file_path() {
202        let result = parse_wave_file_path("wavefile://zone-abc/test.txt");
203        assert_eq!(result, Some(("zone-abc".to_string(), "test.txt".to_string())));
204    }
205
206    #[test]
207    fn test_parse_wave_file_path_nested() {
208        let result = parse_wave_file_path("wavefile://zone/dir/file.txt");
209        assert_eq!(
210            result,
211            Some(("zone".to_string(), "dir/file.txt".to_string()))
212        );
213    }
214
215    #[test]
216    fn test_parse_wave_file_path_invalid() {
217        assert!(parse_wave_file_path("").is_none());
218        assert!(parse_wave_file_path("file:///tmp/foo").is_none());
219        assert!(parse_wave_file_path("wavefile://").is_none());
220        assert!(parse_wave_file_path("wavefile://zone/").is_none());
221        assert!(parse_wave_file_path("wavefile:///file").is_none());
222    }
223}