Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,351 @@
use std::path::Path;
use calamine::{DataType, Reader, Sheets, open_workbook_auto};
use nomifun_api_types::{
CellCoord, CellRange, ConversionResultDto, ConversionTarget, DocumentConversionResponse, ExcelSheetData,
ExcelWorkbookData,
};
use nomifun_runtime::Builder as CmdBuilder;
use serde_json::Value;
use tracing::warn;
use crate::error::OfficeError;
pub struct ConversionService {
officecli_path: Option<String>,
}
impl ConversionService {
pub fn new(officecli_path: Option<String>) -> Self {
Self { officecli_path }
}
pub async fn convert(
&self,
file_path: &str,
target: ConversionTarget,
) -> Result<DocumentConversionResponse, OfficeError> {
let to_str = match target {
ConversionTarget::Markdown => "markdown",
ConversionTarget::ExcelJson => "excel-json",
ConversionTarget::PptJson => "ppt-json",
};
let result = match target {
ConversionTarget::Markdown => self.word_to_markdown(file_path).await,
ConversionTarget::ExcelJson => self.excel_to_json(file_path),
ConversionTarget::PptJson => self.ppt_to_json(file_path).await,
};
let result_dto = match result {
Ok(data) => ConversionResultDto {
success: true,
data: Some(data),
error: None,
},
Err(e) => ConversionResultDto {
success: false,
data: None,
error: Some(e.to_string()),
},
};
Ok(DocumentConversionResponse {
to: to_str.to_string(),
result: result_dto,
})
}
async fn word_to_markdown(&self, file_path: &str) -> Result<Value, OfficeError> {
validate_file_exists(file_path)?;
let pandoc = find_executable("pandoc");
let pandoc_path = pandoc.ok_or_else(|| {
OfficeError::ToolNotFound(
"pandoc not installed. Install it via: brew install pandoc (macOS) \
or apt-get install pandoc (Linux)"
.into(),
)
})?;
let mut builder = CmdBuilder::clean_cli(&pandoc_path);
builder.args(["-f", "docx", "-t", "markdown", "--wrap=none", file_path]);
let output = builder
.output()
.await
.map_err(|e| OfficeError::Conversion(format!("failed to run pandoc: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(OfficeError::Conversion(format!("pandoc failed: {stderr}")));
}
let markdown = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(Value::String(markdown))
}
fn excel_to_json(&self, file_path: &str) -> Result<Value, OfficeError> {
validate_file_exists(file_path)?;
let mut workbook: Sheets<_> = open_workbook_auto(file_path)
.map_err(|e| OfficeError::Conversion(format!("failed to open workbook: {e}")))?;
let sheet_names = workbook.sheet_names().to_vec();
let mut sheets = Vec::with_capacity(sheet_names.len());
for name in &sheet_names {
let range = workbook
.worksheet_range(name)
.map_err(|e| OfficeError::Conversion(format!("failed to read sheet '{name}': {e}")))?;
let data = convert_range_to_2d_array(&range);
let merges = extract_merge_regions(&mut workbook, name);
sheets.push(ExcelSheetData {
name: name.clone(),
data,
merges,
images: None,
});
}
let workbook_data = ExcelWorkbookData { sheets };
serde_json::to_value(workbook_data).map_err(OfficeError::Json)
}
async fn ppt_to_json(&self, file_path: &str) -> Result<Value, OfficeError> {
validate_file_exists(file_path)?;
let officecli = resolve_officecli(&self.officecli_path).await?;
let mut builder = CmdBuilder::clean_cli(&officecli);
builder.args(["ppt2json", file_path]);
let output = builder
.output()
.await
.map_err(|e| OfficeError::Conversion(format!("failed to run officecli ppt2json: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(OfficeError::Conversion(format!("officecli ppt2json failed: {stderr}")));
}
let json: Value = serde_json::from_slice(&output.stdout)
.map_err(|e| OfficeError::Conversion(format!("failed to parse officecli ppt2json output: {e}")))?;
Ok(json)
}
}
fn validate_file_exists(file_path: &str) -> Result<(), OfficeError> {
let path = Path::new(file_path);
if !path.exists() {
return Err(OfficeError::Conversion(format!("file not found: {file_path}")));
}
if !path.is_file() {
return Err(OfficeError::Conversion(format!("not a file: {file_path}")));
}
Ok(())
}
fn convert_range_to_2d_array(range: &calamine::Range<calamine::Data>) -> Vec<Vec<Value>> {
let (rows, cols) = range.get_size();
let mut data = Vec::with_capacity(rows);
for r in 0..rows {
let mut row = Vec::with_capacity(cols);
for c in 0..cols {
let cell = &range[(r, c)];
let value = cell_to_json_value(cell);
row.push(value);
}
data.push(row);
}
data
}
fn cell_to_json_value(cell: &calamine::Data) -> Value {
if cell.is_empty() {
return Value::Null;
}
if let Some(b) = cell.get_bool() {
return Value::Bool(b);
}
if let Some(i) = cell.get_int() {
return Value::Number(i.into());
}
if let Some(f) = cell.get_float() {
return serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null);
}
if let Some(s) = cell.as_string() {
return Value::String(s);
}
Value::Null
}
fn extract_merge_regions<RS: std::io::Read + std::io::Seek>(
workbook: &mut Sheets<RS>,
sheet_name: &str,
) -> Option<Vec<CellRange>> {
let xlsx = match workbook {
Sheets::Xlsx(wb) => wb,
_ => return None,
};
if xlsx.load_merged_regions().is_err() {
warn!("failed to load merged regions");
return None;
}
let regions = xlsx.merged_regions_by_sheet(sheet_name);
if regions.is_empty() {
return None;
}
let ranges: Vec<CellRange> = regions
.into_iter()
.map(|(_, _, dim)| CellRange {
s: CellCoord {
r: dim.start.0 as usize,
c: dim.start.1 as usize,
},
e: CellCoord {
r: dim.end.0 as usize,
c: dim.end.1 as usize,
},
})
.collect();
Some(ranges)
}
fn find_executable(name: &str) -> Option<String> {
which::which(name).ok().map(|p| p.to_string_lossy().into_owned())
}
async fn resolve_officecli(configured_path: &Option<String>) -> Result<String, OfficeError> {
if let Some(path) = configured_path
&& Path::new(path).exists()
{
return Ok(path.clone());
}
if let Some(found) = find_executable("officecli") {
return Ok(found);
}
Err(OfficeError::ToolNotFound(
"officecli not installed. Install it to enable PPT → JSON conversion".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_file_exists_nonexistent() {
let result = validate_file_exists("/nonexistent/file.xlsx");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("file not found"));
}
#[test]
fn validate_file_exists_is_directory() {
let result = validate_file_exists("/tmp");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not a file"));
}
#[test]
fn cell_to_json_value_empty() {
let cell = calamine::Data::Empty;
assert_eq!(cell_to_json_value(&cell), Value::Null);
}
#[test]
fn cell_to_json_value_bool() {
let cell = calamine::Data::Bool(true);
assert_eq!(cell_to_json_value(&cell), Value::Bool(true));
}
#[test]
fn cell_to_json_value_int() {
let cell = calamine::Data::Int(42);
assert_eq!(cell_to_json_value(&cell), serde_json::json!(42));
}
#[test]
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
fn cell_to_json_value_float() {
let cell = calamine::Data::Float(3.14);
let val = cell_to_json_value(&cell);
assert!(val.is_number());
let n = val.as_f64().unwrap();
assert!((n - 3.14).abs() < f64::EPSILON);
}
#[test]
fn cell_to_json_value_string() {
let cell = calamine::Data::String("hello".to_string());
assert_eq!(cell_to_json_value(&cell), Value::String("hello".into()));
}
#[test]
fn convert_range_empty() {
let range = calamine::Range::<calamine::Data>::new((0, 0), (0, 0));
let data = convert_range_to_2d_array(&range);
assert_eq!(data.len(), 1);
assert_eq!(data[0].len(), 1);
}
#[test]
fn conversion_service_new() {
let svc = ConversionService::new(None);
assert!(svc.officecli_path.is_none());
let svc = ConversionService::new(Some("/usr/local/bin/officecli".into()));
assert_eq!(svc.officecli_path.as_deref(), Some("/usr/local/bin/officecli"));
}
#[tokio::test]
async fn convert_excel_file_not_found() {
let svc = ConversionService::new(None);
let resp = svc
.convert("/nonexistent/file.xlsx", ConversionTarget::ExcelJson)
.await
.unwrap();
assert!(!resp.result.success);
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
assert_eq!(resp.to, "excel-json");
}
#[tokio::test]
async fn convert_word_file_not_found() {
let svc = ConversionService::new(None);
let resp = svc
.convert("/nonexistent/file.docx", ConversionTarget::Markdown)
.await
.unwrap();
assert!(!resp.result.success);
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
assert_eq!(resp.to, "markdown");
}
#[tokio::test]
async fn convert_ppt_file_not_found() {
let svc = ConversionService::new(None);
let resp = svc
.convert("/nonexistent/file.pptx", ConversionTarget::PptJson)
.await
.unwrap();
assert!(!resp.result.success);
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
assert_eq!(resp.to, "ppt-json");
}
}
@@ -0,0 +1,116 @@
use nomifun_common::AppError;
#[derive(Debug, thiserror::Error)]
pub enum OfficeError {
#[error("officecli not found")]
OfficecliNotFound,
#[error("officecli install failed: {0}")]
InstallFailed(String),
#[error("preview start failed: {0}")]
StartFailed(String),
#[error("port readiness timeout for {0}")]
PortTimeout(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("snapshot error: {0}")]
Snapshot(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("conversion error: {0}")]
Conversion(String),
#[error("external tool not found: {0}")]
ToolNotFound(String),
}
impl From<OfficeError> for AppError {
fn from(err: OfficeError) -> Self {
match err {
OfficeError::OfficecliNotFound => AppError::BadRequest("officecli not found".into()),
OfficeError::InstallFailed(msg) => AppError::Internal(format!("officecli install failed: {msg}")),
OfficeError::StartFailed(msg) => AppError::Internal(format!("preview start failed: {msg}")),
OfficeError::PortTimeout(path) => AppError::Timeout(format!("port readiness timeout for {path}")),
OfficeError::Io(e) => AppError::Internal(format!("IO error: {e}")),
OfficeError::Snapshot(msg) => AppError::Internal(format!("snapshot error: {msg}")),
OfficeError::Json(e) => AppError::Internal(format!("JSON error: {e}")),
OfficeError::Conversion(msg) => AppError::Internal(format!("conversion error: {msg}")),
OfficeError::ToolNotFound(tool) => AppError::BadRequest(format!("{tool} is not installed")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn officecli_not_found_maps_to_bad_request() {
let err: AppError = OfficeError::OfficecliNotFound.into();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn install_failed_maps_to_internal() {
let err: AppError = OfficeError::InstallFailed("npm error".into()).into();
assert!(matches!(err, AppError::Internal(msg) if msg.contains("npm error")));
}
#[test]
fn start_failed_maps_to_internal() {
let err: AppError = OfficeError::StartFailed("spawn error".into()).into();
assert!(matches!(err, AppError::Internal(msg) if msg.contains("spawn error")));
}
#[test]
fn port_timeout_maps_to_timeout() {
let err: AppError = OfficeError::PortTimeout("/a.docx".into()).into();
assert!(matches!(err, AppError::Timeout(msg) if msg.contains("/a.docx")));
}
#[test]
fn io_error_maps_to_internal() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let err: AppError = OfficeError::Io(io_err).into();
assert!(matches!(err, AppError::Internal(msg) if msg.contains("file missing")));
}
#[test]
fn conversion_error_maps_to_internal() {
let err: AppError = OfficeError::Conversion("bad format".into()).into();
assert!(matches!(err, AppError::Internal(msg) if msg.contains("bad format")));
}
#[test]
fn tool_not_found_maps_to_bad_request() {
let err: AppError = OfficeError::ToolNotFound("pandoc".into()).into();
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("pandoc")));
}
#[test]
fn display_messages() {
assert_eq!(OfficeError::OfficecliNotFound.to_string(), "officecli not found");
assert_eq!(
OfficeError::InstallFailed("npm error".into()).to_string(),
"officecli install failed: npm error"
);
assert_eq!(
OfficeError::PortTimeout("/a.docx".into()).to_string(),
"port readiness timeout for /a.docx"
);
assert_eq!(
OfficeError::Conversion("bad data".into()).to_string(),
"conversion error: bad data"
);
assert_eq!(
OfficeError::ToolNotFound("pandoc".into()).to_string(),
"external tool not found: pandoc"
);
}
}
@@ -0,0 +1,21 @@
//! Office document preview, format conversion, proxy, and snapshot management.
pub mod conversion;
pub mod error;
pub mod port;
pub mod proxy;
pub mod routes;
pub mod snapshot;
pub mod star_office;
pub mod state;
pub mod types;
pub mod watch_manager;
pub use conversion::ConversionService;
pub use error::OfficeError;
pub use proxy::{ProxyError, ProxyService};
pub use routes::{office_proxy_routes, office_routes};
pub use snapshot::SnapshotService;
pub use star_office::StarOfficeDetector;
pub use state::OfficeRouterState;
pub use types::{DocType, OfficecliStatus};
pub use watch_manager::{DefaultProcessSpawner, OfficecliWatchManager, ProcessHandle, ProcessSpawner};
@@ -0,0 +1,44 @@
use std::net::TcpListener;
use crate::error::OfficeError;
pub fn allocate_port() -> Result<u16, OfficeError> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Ok(port)
}
pub async fn is_port_listening(port: u16) -> bool {
tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allocate_port_returns_nonzero() {
let port = allocate_port().unwrap();
assert!(port > 0);
}
#[test]
fn allocate_port_returns_different_ports() {
let p1 = allocate_port().unwrap();
let p2 = allocate_port().unwrap();
assert_ne!(p1, p2);
}
#[tokio::test]
async fn is_port_listening_false_for_unused_port() {
let port = allocate_port().unwrap();
assert!(!is_port_listening(port).await);
}
#[tokio::test]
async fn is_port_listening_true_for_active_port() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
assert!(is_port_listening(port).await);
}
}
@@ -0,0 +1,487 @@
use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{CONTENT_TYPE, HOST, HeaderMap, HeaderName, HeaderValue};
use crate::types::DocType;
use crate::watch_manager::OfficecliWatchManager;
const PROXY_TIMEOUT: Duration = Duration::from_secs(30);
const HOP_BY_HOP_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"cookie",
"authorization",
];
const NAVIGATION_GUARD_TEMPLATE: &str = r#"<script>
(function(b){
function rw(u){if(!u)return u;var s=String(u);var m=/^https?:\/\/(?:localhost|127\.0\.0\.1)(:\d+)?(\/.*)?$/.exec(s);if(m){var p=m[2]||'/';if(!p.startsWith(b))return b+(p==='/'?'/':p);}if(s==='/'||(s[0]==='/'&&s[1]!=='/'&&!s.startsWith(b)))return b+(s==='/'?'/':s);return s;}
var _a=location.assign.bind(location),_r=location.replace.bind(location);
location.assign=function(u){_a(rw(u));};location.replace=function(u){_r(rw(u));};
var _ps=history.pushState.bind(history),_rs=history.replaceState.bind(history);
history.pushState=function(s,t,u){_ps(s,t,u?rw(u):u);};history.replaceState=function(s,t,u){_rs(s,t,u?rw(u):u);};
try{Object.defineProperty(location,'href',{set:function(v){_a(rw(v));},configurable:true});}catch(e){}
document.addEventListener('click',function(e){var t=e.target;while(t&&t.tagName!=='A')t=t.parentElement;if(t&&t.tagName==='A'){var h=t.getAttribute('href');if(h&&(h[0]==='/'&&h[1]!=='/'&&!h.startsWith(b))){e.preventDefault();_a(b+h);}}},true);
})('PROXY_BASE_PLACEHOLDER');
</script>"#;
pub struct ProxyService {
watch_manager: Arc<OfficecliWatchManager>,
client: reqwest::Client,
}
#[derive(Debug)]
pub struct ProxyResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl ProxyService {
pub fn new(watch_manager: Arc<OfficecliWatchManager>) -> Self {
let client = reqwest::Client::builder()
.timeout(PROXY_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build proxy HTTP client");
Self { watch_manager, client }
}
pub async fn forward(
&self,
port: u16,
path: &str,
doc_type: DocType,
request_headers: &[(String, String)],
) -> Result<ProxyResponse, ProxyError> {
if !self.watch_manager.is_active_port(port, doc_type) {
return Err(ProxyError::PortNotActive(port));
}
let proxy_base = format!("/api/{}/{}", doc_type.proxy_prefix(), port);
self.forward_inner(port, path, &proxy_base, request_headers).await
}
pub async fn forward_watch(
&self,
port: u16,
path: &str,
request_headers: &[(String, String)],
) -> Result<ProxyResponse, ProxyError> {
if !self.watch_manager.is_active_watch_port(port) {
return Err(ProxyError::PortNotActive(port));
}
let proxy_base = format!("/api/office-watch-proxy/{port}");
self.forward_inner(port, path, &proxy_base, request_headers).await
}
async fn forward_inner(
&self,
port: u16,
path: &str,
proxy_base: &str,
request_headers: &[(String, String)],
) -> Result<ProxyResponse, ProxyError> {
let target_url = build_target_url(port, path);
let mut req_headers = HeaderMap::new();
req_headers.insert(
HOST,
HeaderValue::from_str(&format!("127.0.0.1:{port}"))
.unwrap_or_else(|_| HeaderValue::from_static("127.0.0.1")),
);
for (key, value) in request_headers {
let lower = key.to_lowercase();
if is_hop_by_hop(&lower) {
continue;
}
if lower == "host" {
continue;
}
if let (Ok(name), Ok(val)) = (HeaderName::from_bytes(lower.as_bytes()), HeaderValue::from_str(value)) {
req_headers.insert(name, val);
}
}
let resp = self
.client
.get(&target_url)
.headers(req_headers)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
ProxyError::Timeout
} else if e.is_connect() {
ProxyError::ConnectionFailed(e.to_string())
} else {
ProxyError::RequestFailed(e.to_string())
}
})?;
let status = resp.status().as_u16();
let resp_headers = resp.headers().clone();
let body = resp
.bytes()
.await
.map_err(|e| ProxyError::RequestFailed(format!("failed to read response body: {e}")))?;
let mut out_headers = Vec::new();
let is_html = is_html_content_type(&resp_headers);
let mut body_bytes = body.to_vec();
for (name, value) in resp_headers.iter() {
let name_str = name.as_str().to_lowercase();
if is_hop_by_hop(&name_str) {
continue;
}
if name_str == "location"
&& let Ok(loc) = value.to_str()
{
let rewritten = rewrite_location(loc, port, proxy_base);
out_headers.push(("location".to_owned(), rewritten));
continue;
}
if is_html && name_str == "content-length" {
continue;
}
if let Ok(val_str) = value.to_str() {
out_headers.push((name_str, val_str.to_owned()));
}
}
out_headers.push(("x-frame-options".to_owned(), "SAMEORIGIN".to_owned()));
if is_html {
body_bytes = inject_navigation_guard(&body_bytes, proxy_base);
}
Ok(ProxyResponse {
status,
headers: out_headers,
body: body_bytes,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum ProxyError {
#[error("port {0} is not an active preview port")]
PortNotActive(u16),
#[error("proxy request timed out")]
Timeout,
#[error("connection to preview server failed: {0}")]
ConnectionFailed(String),
#[error("proxy request failed: {0}")]
RequestFailed(String),
}
impl From<ProxyError> for nomifun_common::AppError {
fn from(err: ProxyError) -> Self {
match err {
ProxyError::PortNotActive(_) => nomifun_common::AppError::Forbidden(err.to_string()),
ProxyError::Timeout => nomifun_common::AppError::Timeout(err.to_string()),
ProxyError::ConnectionFailed(msg) => nomifun_common::AppError::BadGateway(msg),
ProxyError::RequestFailed(msg) => nomifun_common::AppError::BadGateway(msg),
}
}
}
fn build_target_url(port: u16, path: &str) -> String {
let normalized = if path.starts_with('/') {
path.to_owned()
} else {
format!("/{path}")
};
format!("http://127.0.0.1:{port}{normalized}")
}
fn is_hop_by_hop(header: &str) -> bool {
HOP_BY_HOP_HEADERS.contains(&header)
}
fn is_html_content_type(headers: &HeaderMap) -> bool {
headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| ct.contains("text/html"))
}
fn rewrite_location(location: &str, port: u16, proxy_base: &str) -> String {
let pattern = format!("http://localhost:{port}");
let pattern_ip = format!("http://127.0.0.1:{port}");
let rewritten = if location.starts_with(&pattern) {
format!("{proxy_base}{}", &location[pattern.len()..])
} else if location.starts_with(&pattern_ip) {
format!("{proxy_base}{}", &location[pattern_ip.len()..])
} else {
location.to_owned()
};
if rewritten == "/"
|| (rewritten.starts_with('/') && !rewritten.starts_with("//") && !rewritten.starts_with(proxy_base))
{
if rewritten == "/" {
format!("{proxy_base}/")
} else {
format!("{proxy_base}{rewritten}")
}
} else {
rewritten
}
}
fn inject_navigation_guard(body: &[u8], proxy_base: &str) -> Vec<u8> {
let html = String::from_utf8_lossy(body);
let guard_script = NAVIGATION_GUARD_TEMPLATE.replace("PROXY_BASE_PLACEHOLDER", proxy_base);
let result = if let Some(pos) = find_head_tag_end(&html) {
format!("{}{}{}", &html[..pos], guard_script, &html[pos..])
} else {
format!("{guard_script}{html}")
};
result.into_bytes()
}
fn find_head_tag_end(html: &str) -> Option<usize> {
let lower = html.to_lowercase();
let head_start = lower.find("<head")?;
let tag_end = lower[head_start..].find('>')?;
Some(head_start + tag_end + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_target_url_with_leading_slash() {
assert_eq!(
build_target_url(8080, "/index.html"),
"http://127.0.0.1:8080/index.html"
);
}
#[test]
fn build_target_url_without_leading_slash() {
assert_eq!(build_target_url(8080, "index.html"), "http://127.0.0.1:8080/index.html");
}
#[test]
fn build_target_url_root() {
assert_eq!(build_target_url(3000, "/"), "http://127.0.0.1:3000/");
}
#[test]
fn build_target_url_empty_path() {
assert_eq!(build_target_url(3000, ""), "http://127.0.0.1:3000/");
}
#[test]
fn is_hop_by_hop_recognizes_all_headers() {
for h in HOP_BY_HOP_HEADERS {
assert!(is_hop_by_hop(h), "expected {h} to be hop-by-hop");
}
}
#[test]
fn is_hop_by_hop_rejects_normal_headers() {
assert!(!is_hop_by_hop("content-type"));
assert!(!is_hop_by_hop("accept"));
assert!(!is_hop_by_hop("x-custom-header"));
}
#[test]
fn is_html_content_type_detects_html() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
assert!(is_html_content_type(&headers));
}
#[test]
fn is_html_content_type_rejects_json() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
assert!(!is_html_content_type(&headers));
}
#[test]
fn is_html_content_type_empty_headers() {
let headers = HeaderMap::new();
assert!(!is_html_content_type(&headers));
}
#[test]
fn rewrite_location_localhost_absolute() {
let result = rewrite_location("http://localhost:8080/new/path", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "/api/ppt-proxy/8080/new/path");
}
#[test]
fn rewrite_location_ip_absolute() {
let result = rewrite_location("http://127.0.0.1:8080/new/path", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "/api/ppt-proxy/8080/new/path");
}
#[test]
fn rewrite_location_root_relative() {
let result = rewrite_location("/foo/bar", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "/api/ppt-proxy/8080/foo/bar");
}
#[test]
fn rewrite_location_root_path() {
let result = rewrite_location("/", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "/api/ppt-proxy/8080/");
}
#[test]
fn rewrite_location_already_proxied() {
let result = rewrite_location("/api/ppt-proxy/8080/existing", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "/api/ppt-proxy/8080/existing");
}
#[test]
fn rewrite_location_external_url_unchanged() {
let result = rewrite_location("https://example.com/path", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "https://example.com/path");
}
#[test]
fn rewrite_location_different_port_unchanged() {
let result = rewrite_location("http://localhost:9999/path", 8080, "/api/ppt-proxy/8080");
assert_eq!(result, "http://localhost:9999/path");
}
#[test]
fn rewrite_location_localhost_root() {
let result = rewrite_location("http://localhost:3000", 3000, "/api/office-watch-proxy/3000");
assert_eq!(result, "/api/office-watch-proxy/3000");
}
#[test]
fn inject_guard_with_head_tag() {
let html = b"<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
let result_str = String::from_utf8(result).unwrap();
assert!(result_str.contains("<head><script>"));
assert!(result_str.contains("'/api/ppt-proxy/8080'"));
assert!(result_str.contains("<title>Test</title>"));
}
#[test]
fn inject_guard_with_head_attributes() {
let html = b"<html><head lang=\"en\"><title>Test</title></head></html>";
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
let result_str = String::from_utf8(result).unwrap();
assert!(result_str.contains("lang=\"en\"><script>"));
}
#[test]
fn inject_guard_no_head_tag() {
let html = b"<html><body>content</body></html>";
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
let result_str = String::from_utf8(result).unwrap();
assert!(result_str.starts_with("<script>"));
assert!(result_str.contains("content</body>"));
}
#[test]
fn inject_guard_uppercase_head() {
let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD></HTML>";
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
let result_str = String::from_utf8(result).unwrap();
assert!(result_str.contains("<HEAD><script>"));
}
#[test]
fn find_head_tag_end_normal() {
assert_eq!(find_head_tag_end("<html><head><title>"), Some(12));
}
#[test]
fn find_head_tag_end_with_attrs() {
let html = "<html><head lang=\"en\">";
assert_eq!(find_head_tag_end(html), Some(html.len()));
}
#[test]
fn find_head_tag_end_uppercase() {
assert_eq!(find_head_tag_end("<html><HEAD>"), Some(12));
}
#[test]
fn find_head_tag_end_missing() {
assert_eq!(find_head_tag_end("<html><body>"), None);
}
#[test]
fn proxy_error_port_not_active_to_forbidden() {
let err: nomifun_common::AppError = ProxyError::PortNotActive(8080).into();
assert!(matches!(err, nomifun_common::AppError::Forbidden(_)));
}
#[test]
fn proxy_error_timeout_to_timeout() {
let err: nomifun_common::AppError = ProxyError::Timeout.into();
assert!(matches!(err, nomifun_common::AppError::Timeout(_)));
}
#[test]
fn proxy_error_connection_failed_to_bad_gateway() {
let err: nomifun_common::AppError = ProxyError::ConnectionFailed("refused".into()).into();
assert!(matches!(err, nomifun_common::AppError::BadGateway(_)));
}
#[test]
fn proxy_error_request_failed_to_bad_gateway() {
let err: nomifun_common::AppError = ProxyError::RequestFailed("network error".into()).into();
assert!(matches!(err, nomifun_common::AppError::BadGateway(_)));
}
#[test]
fn proxy_error_display() {
assert_eq!(
ProxyError::PortNotActive(8080).to_string(),
"port 8080 is not an active preview port"
);
assert_eq!(ProxyError::Timeout.to_string(), "proxy request timed out");
assert_eq!(
ProxyError::ConnectionFailed("refused".into()).to_string(),
"connection to preview server failed: refused"
);
}
#[test]
fn navigation_guard_contains_all_intercepts() {
let guard = NAVIGATION_GUARD_TEMPLATE;
assert!(guard.contains("location.assign"));
assert!(guard.contains("location.replace"));
assert!(guard.contains("history.pushState"));
assert!(guard.contains("history.replaceState"));
assert!(guard.contains("location,'href'"));
assert!(guard.contains("addEventListener('click'"));
}
}
@@ -0,0 +1,369 @@
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use std::path::{Path as FsPath, PathBuf};
use nomifun_api_types::{
ApiResponse, DetectStarOfficeRequest, DocumentConversionRequest, GetSnapshotContentRequest, ListSnapshotsRequest,
PreviewSnapshotInfoDto, PreviewUrlResponse, SaveSnapshotRequest, SnapshotContentResponse, StarOfficeDetectResponse,
StartPreviewRequest, StopPreviewRequest,
};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use nomifun_file::path_safety::validate_path_with_extra_root;
use crate::error::OfficeError;
use crate::state::OfficeRouterState;
use crate::types::DocType;
pub fn office_routes(state: OfficeRouterState) -> Router {
Router::new()
.route("/api/word-preview/start", post(start_word_preview))
.route("/api/word-preview/stop", post(stop_word_preview))
.route("/api/excel-preview/start", post(start_excel_preview))
.route("/api/excel-preview/stop", post(stop_excel_preview))
.route("/api/ppt-preview/start", post(start_ppt_preview))
.route("/api/ppt-preview/stop", post(stop_ppt_preview))
.route("/api/preview-history/list", post(list_snapshots))
.route("/api/preview-history/save", post(save_snapshot))
.route("/api/preview-history/get-content", post(get_snapshot_content))
.route("/api/star-office/detect", post(detect_star_office))
.route("/api/document/convert", post(convert_document))
.with_state(state)
}
pub fn office_proxy_routes(state: OfficeRouterState) -> Router {
Router::new()
.route("/api/ppt-proxy/{port}", get(ppt_proxy))
.route("/api/ppt-proxy/{port}/{*path}", get(ppt_proxy))
.route("/api/office-watch-proxy/{port}", get(office_watch_proxy))
.route("/api/office-watch-proxy/{port}/{*path}", get(office_watch_proxy))
.with_state(state)
}
#[derive(serde::Deserialize)]
struct ProxyPortPath {
port: u16,
path: Option<String>,
}
// -- Preview start/stop handlers ------------------------------------------
async fn start_word_preview(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<StartPreviewRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
start_preview(state, body, DocType::Word).await
}
async fn stop_word_preview(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<StopPreviewRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
stop_preview(state, body, DocType::Word).await
}
async fn start_excel_preview(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<StartPreviewRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
start_preview(state, body, DocType::Excel).await
}
async fn stop_excel_preview(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<StopPreviewRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
stop_preview(state, body, DocType::Excel).await
}
async fn start_ppt_preview(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<StartPreviewRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
start_preview(state, body, DocType::Ppt).await
}
async fn stop_ppt_preview(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<StopPreviewRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
stop_preview(state, body, DocType::Ppt).await
}
async fn start_preview(
state: OfficeRouterState,
body: Result<Json<StartPreviewRequest>, JsonRejection>,
doc_type: DocType,
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let validated_path = validate_office_path(&state, &req.file_path, req.workspace.as_deref())?;
let validated_path = validated_path.to_string_lossy().into_owned();
let result = state.watch_manager.start(&validated_path, doc_type).await;
let resp = match result {
Ok(port) => {
let url = format!("/api/{}/{}", doc_type.proxy_prefix(), port);
PreviewUrlResponse { url, error: None }
}
Err(e) => PreviewUrlResponse {
url: String::new(),
error: Some(preview_error_code(&e).to_owned()),
},
};
Ok(Json(ApiResponse::ok(resp)))
}
async fn stop_preview(
state: OfficeRouterState,
body: Result<Json<StopPreviewRequest>, JsonRejection>,
doc_type: DocType,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.watch_manager.stop(&req.file_path, doc_type).await;
Ok(Json(ApiResponse::success()))
}
// -- Snapshot handlers ----------------------------------------------------
async fn list_snapshots(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<ListSnapshotsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Vec<PreviewSnapshotInfoDto>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let snapshots = state.snapshot_service.list(&req.target).await?;
Ok(Json(ApiResponse::ok(snapshots)))
}
async fn save_snapshot(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<SaveSnapshotRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<PreviewSnapshotInfoDto>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let info = state.snapshot_service.save(&req.target, &req.content).await?;
Ok(Json(ApiResponse::ok(info)))
}
async fn get_snapshot_content(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<GetSnapshotContentRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Option<SnapshotContentResponse>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state
.snapshot_service
.get_content(&req.target, &req.snapshot_id)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
// -- Star Office detection ------------------------------------------------
async fn detect_star_office(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<DetectStarOfficeRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<StarOfficeDetectResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let url = state
.star_office_detector
.detect(req.preferred_url.as_deref(), req.force.unwrap_or(false), req.timeout_ms)
.await;
Ok(Json(ApiResponse::ok(StarOfficeDetectResponse { url })))
}
// -- Document conversion --------------------------------------------------
async fn convert_document(
State(state): State<OfficeRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<DocumentConversionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<nomifun_api_types::DocumentConversionResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let validated_path = validate_office_path(&state, &req.file_path, req.workspace.as_deref())?;
let resp = state
.conversion_service
.convert(validated_path.to_string_lossy().as_ref(), req.to)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
fn validate_office_path(
state: &OfficeRouterState,
file_path: &str,
workspace: Option<&str>,
) -> Result<PathBuf, AppError> {
let allowed_roots: Vec<&FsPath> = state.allowed_roots.iter().map(PathBuf::as_path).collect();
validate_path_with_extra_root(file_path, &allowed_roots, workspace.map(FsPath::new))
}
fn preview_error_code(error: &OfficeError) -> &'static str {
match error {
OfficeError::OfficecliNotFound => "OFFICECLI_NOT_FOUND",
OfficeError::InstallFailed(_) => "OFFICECLI_INSTALL_FAILED",
OfficeError::PortTimeout(_) => "OFFICECLI_PORT_TIMEOUT",
OfficeError::StartFailed(_)
| OfficeError::Io(_)
| OfficeError::Snapshot(_)
| OfficeError::Json(_)
| OfficeError::Conversion(_)
| OfficeError::ToolNotFound(_) => "OFFICECLI_START_FAILED",
}
}
// -- Reverse proxy handlers -----------------------------------------------
async fn ppt_proxy(
State(state): State<OfficeRouterState>,
Path(params): Path<ProxyPortPath>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let path = params.path.as_deref().unwrap_or("/");
proxy_forward(state, params.port, path, DocType::Ppt, &headers).await
}
async fn office_watch_proxy(
State(state): State<OfficeRouterState>,
Path(params): Path<ProxyPortPath>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let path = params.path.as_deref().unwrap_or("/");
let request_headers: Vec<(String, String)> = headers
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.as_str().to_owned(), val.to_owned())))
.collect();
let proxy_resp = state
.proxy_service
.forward_watch(params.port, path, &request_headers)
.await?;
let status = StatusCode::from_u16(proxy_resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut response = axum::response::Response::builder().status(status);
for (key, value) in &proxy_resp.headers {
response = response.header(key.as_str(), value.as_str());
}
Ok(response
.body(axum::body::Body::from(proxy_resp.body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
}
async fn proxy_forward(
state: OfficeRouterState,
port: u16,
path: &str,
doc_type: DocType,
headers: &HeaderMap,
) -> Result<Response, AppError> {
let request_headers: Vec<(String, String)> = headers
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.as_str().to_owned(), val.to_owned())))
.collect();
let proxy_resp = state
.proxy_service
.forward(port, path, doc_type, &request_headers)
.await?;
let status = StatusCode::from_u16(proxy_resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut response = axum::response::Response::builder().status(status);
for (key, value) in &proxy_resp.headers {
response = response.header(key.as_str(), value.as_str());
}
Ok(response
.body(axum::body::Body::from(proxy_resp.body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::conversion::ConversionService;
use crate::error::OfficeError;
use crate::proxy::ProxyService;
use crate::snapshot::SnapshotService;
use crate::star_office::StarOfficeDetector;
use crate::state::OfficeRouterState;
use crate::types::DocType;
use crate::watch_manager::{OfficecliWatchManager, ProcessHandle, ProcessSpawner};
use super::{office_proxy_routes, office_routes};
#[test]
fn office_routes_builds_without_panic() {
let state = build_test_state();
let _router = office_routes(state);
}
#[test]
fn office_proxy_routes_builds_without_panic() {
let state = build_test_state();
let _router = office_proxy_routes(state);
}
fn build_test_state() -> OfficeRouterState {
struct NoopSpawner;
#[async_trait::async_trait]
impl ProcessSpawner for NoopSpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
_port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
Err(OfficeError::OfficecliNotFound)
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
Err(OfficeError::InstallFailed("noop".into()))
}
async fn is_officecli_installed(&self) -> bool {
false
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
Ok(())
}
}
struct NoopBroadcaster;
impl nomifun_realtime::EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _msg: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
let spawner = Arc::new(NoopSpawner);
let bc: Arc<dyn nomifun_realtime::EventBroadcaster> = Arc::new(NoopBroadcaster);
let wm = Arc::new(OfficecliWatchManager::new(spawner, bc));
let snapshot = Arc::new(SnapshotService::new(std::path::Path::new("/tmp/test")));
let detector = Arc::new(StarOfficeDetector::new(reqwest::Client::new()));
let conversion = Arc::new(ConversionService::new(None));
let proxy = Arc::new(ProxyService::new(wm.clone()));
OfficeRouterState {
watch_manager: wm,
snapshot_service: snapshot,
star_office_detector: detector,
conversion_service: conversion,
proxy_service: proxy,
allowed_roots: vec![std::env::temp_dir()],
}
}
}
@@ -0,0 +1,463 @@
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use nomifun_api_types::{PreviewHistoryTargetDto, PreviewSnapshotInfoDto, SnapshotContentResponse};
use sha1::{Digest, Sha1};
use tracing::warn;
use crate::error::OfficeError;
const MAX_SNAPSHOTS: usize = 50;
const SNAPSHOT_EXT: &str = ".md";
const INDEX_FILE: &str = "index.json";
pub struct SnapshotService {
base_dir: PathBuf,
}
impl SnapshotService {
pub fn new(data_dir: &Path) -> Self {
Self {
base_dir: data_dir.join("preview-history"),
}
}
pub async fn list(&self, target: &PreviewHistoryTargetDto) -> Result<Vec<PreviewSnapshotInfoDto>, OfficeError> {
let dir = self.target_dir(target);
let index_path = dir.join(INDEX_FILE);
if !index_path.exists() {
return Ok(Vec::new());
}
let content = tokio::fs::read_to_string(&index_path).await?;
let mut snapshots: Vec<PreviewSnapshotInfoDto> = serde_json::from_str(&content)?;
snapshots.sort_by_key(|a| a.created_at);
Ok(snapshots)
}
pub async fn save(
&self,
target: &PreviewHistoryTargetDto,
content: &str,
) -> Result<PreviewSnapshotInfoDto, OfficeError> {
let dir = self.target_dir(target);
tokio::fs::create_dir_all(&dir).await?;
let now_ms = current_timestamp_ms();
let random_suffix = random_hex();
let id = format!("{now_ms}-{random_suffix}");
let file_name = format!("{id}{SNAPSHOT_EXT}");
let file_path = dir.join(&file_name);
tokio::fs::write(&file_path, content.as_bytes()).await?;
let label = format_label(now_ms);
let info = PreviewSnapshotInfoDto {
id,
label,
created_at: now_ms,
size: content.len() as u64,
content_type: target.content_type,
file_name: target.file_name.clone(),
file_path: target.file_path.clone(),
};
let mut snapshots: Vec<PreviewSnapshotInfoDto> = self.read_index(&dir).await;
snapshots.push(info.clone());
self.trim_and_write_index(&dir, &mut snapshots).await?;
Ok(info)
}
pub async fn get_content(
&self,
target: &PreviewHistoryTargetDto,
snapshot_id: &str,
) -> Result<Option<SnapshotContentResponse>, OfficeError> {
let dir = self.target_dir(target);
let snapshots: Vec<PreviewSnapshotInfoDto> = self.read_index(&dir).await;
let Some(info) = snapshots.into_iter().find(|s| s.id == snapshot_id) else {
return Ok(None);
};
let file_path = dir.join(format!("{snapshot_id}{SNAPSHOT_EXT}"));
if !file_path.exists() {
return Ok(None);
}
let content = tokio::fs::read_to_string(&file_path).await?;
Ok(Some(SnapshotContentResponse {
snapshot: info,
content,
}))
}
fn target_dir(&self, target: &PreviewHistoryTargetDto) -> PathBuf {
let hash = compute_target_hash(target);
self.base_dir.join(hash)
}
async fn read_index(&self, dir: &Path) -> Vec<PreviewSnapshotInfoDto> {
let index_path = dir.join(INDEX_FILE);
let Ok(content) = tokio::fs::read_to_string(&index_path).await else {
return Vec::new();
};
serde_json::from_str(&content).unwrap_or_default()
}
async fn trim_and_write_index(
&self,
dir: &Path,
snapshots: &mut Vec<PreviewSnapshotInfoDto>,
) -> Result<(), OfficeError> {
snapshots.sort_by_key(|a| a.created_at);
while snapshots.len() > MAX_SNAPSHOTS {
if let Some(oldest) = snapshots.first() {
let file_path = dir.join(format!("{}{SNAPSHOT_EXT}", oldest.id));
if let Err(e) = tokio::fs::remove_file(&file_path).await {
warn!(path = %file_path.display(), error = %e, "failed to remove old snapshot file");
}
}
snapshots.remove(0);
}
let index_path = dir.join(INDEX_FILE);
let json = serde_json::to_string_pretty(snapshots)?;
tokio::fs::write(&index_path, json.as_bytes()).await?;
Ok(())
}
}
fn compute_target_hash(target: &PreviewHistoryTargetDto) -> String {
let mut hasher = Sha1::new();
hasher.update(
serde_json::to_value(target.content_type)
.unwrap_or_default()
.as_str()
.unwrap_or_default()
.as_bytes(),
);
hasher.update(b"\0");
let fields: [Option<&str>; 5] = [
target.file_path.as_deref(),
target.workspace.as_deref(),
target.file_name.as_deref(),
target.title.as_deref(),
target.language.as_deref(),
];
for field in &fields {
if let Some(val) = field {
hasher.update(val.as_bytes());
}
hasher.update(b"\0");
}
// conversation_id is now an integer; fold its canonical decimal form into the
// hash so the snapshot key still varies per conversation.
if let Some(conv_id) = target.conversation_id {
hasher.update(conv_id.to_string().as_bytes());
}
hasher.update(b"\0");
let result = hasher.finalize();
result.iter().fold(String::with_capacity(40), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})
}
fn current_timestamp_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn random_hex() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let pid = std::process::id();
format!("{:08x}", nanos ^ pid)
}
fn format_label(timestamp_ms: i64) -> String {
let secs = timestamp_ms / 1000;
let minutes = (secs / 60) % 60;
let hours = (secs / 3600) % 24;
let mut days = secs / 86400;
let mut year: i64 = 1970;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 0;
let mut day = days;
for (i, &md) in month_days.iter().enumerate() {
let md = if i == 1 && is_leap_year(year) { md + 1 } else { md };
if day < md {
month = i + 1;
break;
}
day -= md;
}
if month == 0 {
month = 12;
}
format!("{year:04}-{month:02}-{:02} {hours:02}:{minutes:02}", day + 1,)
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_common::PreviewContentType;
#[test]
fn compute_hash_deterministic() {
let target = PreviewHistoryTargetDto {
content_type: PreviewContentType::Markdown,
file_path: Some("/a.md".into()),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
};
let h1 = compute_target_hash(&target);
let h2 = compute_target_hash(&target);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 40);
}
#[test]
fn compute_hash_different_targets() {
let t1 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Markdown,
file_path: Some("/a.md".into()),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
};
let t2 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Markdown,
file_path: Some("/b.md".into()),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
};
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
}
#[test]
fn compute_hash_extra_fields_differ() {
let t1 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Code,
file_path: Some("/x.rs".into()),
workspace: Some("/ws".into()),
file_name: None,
title: None,
language: None,
conversation_id: Some(1),
};
let t2 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Code,
file_path: Some("/x.rs".into()),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
};
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
}
#[test]
fn random_hex_returns_8_chars() {
let hex = random_hex();
assert_eq!(hex.len(), 8);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn timestamp_ms_positive() {
let ts = current_timestamp_ms();
assert!(ts > 0);
}
#[test]
fn format_label_known_timestamps() {
// 2023-11-14 22:13 UTC
assert_eq!(format_label(1700000000000), "2023-11-14 22:13");
// 1970-01-01 00:00 UTC (epoch)
assert_eq!(format_label(0), "1970-01-01 00:00");
// 2000-02-29 00:00 UTC (leap year)
assert_eq!(format_label(951782400000), "2000-02-29 00:00");
// 2000-03-01 00:00 UTC (day after leap)
assert_eq!(format_label(951868800000), "2000-03-01 00:00");
// 2024-12-31 23:59 UTC
assert_eq!(format_label(1735689540000), "2024-12-31 23:59");
}
#[test]
fn compute_hash_field_boundary_collision() {
// file_path="/foobar" vs file_path="/foo" + workspace="bar"
let t1 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Markdown,
file_path: Some("/foobar".into()),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
};
let t2 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Markdown,
file_path: Some("/foo".into()),
workspace: Some("bar".into()),
file_name: None,
title: None,
language: None,
conversation_id: None,
};
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
}
#[test]
fn compute_hash_field_position_collision() {
// file_name="x" vs title="x"
let t1 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Code,
file_path: None,
workspace: None,
file_name: Some("x".into()),
title: None,
language: None,
conversation_id: None,
};
let t2 = PreviewHistoryTargetDto {
content_type: PreviewContentType::Code,
file_path: None,
workspace: None,
file_name: None,
title: Some("x".into()),
language: None,
conversation_id: None,
};
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
}
#[tokio::test]
async fn service_list_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let result = svc.list(&target).await.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn service_save_and_list() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let info = svc.save(&target, "# Hello").await.unwrap();
assert!(!info.id.is_empty());
assert_eq!(info.size, 7);
assert_eq!(info.content_type, PreviewContentType::Markdown);
let list = svc.list(&target).await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, info.id);
}
#[tokio::test]
async fn service_get_content() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Html, Some("/b.html"));
let info = svc.save(&target, "<h1>Hi</h1>").await.unwrap();
let resp = svc.get_content(&target, &info.id).await.unwrap().unwrap();
assert_eq!(resp.content, "<h1>Hi</h1>");
assert_eq!(resp.snapshot.id, info.id);
}
#[tokio::test]
async fn service_get_content_nonexistent() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let resp = svc.get_content(&target, "nonexistent").await.unwrap();
assert!(resp.is_none());
}
#[tokio::test]
async fn service_trim_over_limit() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
for i in 0..52 {
svc.save(&target, &format!("content-{i}")).await.unwrap();
}
let list = svc.list(&target).await.unwrap();
assert_eq!(list.len(), MAX_SNAPSHOTS);
}
#[tokio::test]
async fn service_different_targets_isolated() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
let t2 = make_target(PreviewContentType::Markdown, Some("/b.md"));
svc.save(&t1, "content-a").await.unwrap();
svc.save(&t2, "content-b1").await.unwrap();
svc.save(&t2, "content-b2").await.unwrap();
let list1 = svc.list(&t1).await.unwrap();
let list2 = svc.list(&t2).await.unwrap();
assert_eq!(list1.len(), 1);
assert_eq!(list2.len(), 2);
}
fn make_target(content_type: PreviewContentType, file_path: Option<&str>) -> PreviewHistoryTargetDto {
PreviewHistoryTargetDto {
content_type,
file_path: file_path.map(String::from),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
}
}
}
@@ -0,0 +1,375 @@
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::Instant;
const DEFAULT_TIMEOUT_MS: u64 = 1000;
const CACHE_HIT_TTL: Duration = Duration::from_secs(20);
const CACHE_MISS_TTL: Duration = Duration::from_millis(1500);
const MAX_CONCURRENT_WORKERS: usize = 6;
const SCAN_RADIUS: u16 = 24;
const KNOWN_PORTS: [u16; 2] = [19000, 18791];
const STATUS_MARKERS: [&str; 6] = ["idle", "writing", "researching", "executing", "syncing", "error"];
const FEATURE_KEYWORDS: [&str; 3] = ["star office", "decorate room", "asset sidebar"];
const EXCLUDE_KEYWORDS: [&str; 1] = ["openclaw control"];
struct DetectCache {
url: Option<String>,
cached_at: Instant,
was_hit: bool,
}
pub struct StarOfficeDetector {
cache: Mutex<Option<DetectCache>>,
client: reqwest::Client,
}
impl StarOfficeDetector {
pub fn new(client: reqwest::Client) -> Self {
Self {
cache: Mutex::new(None),
client,
}
}
pub async fn detect(&self, preferred_url: Option<&str>, force: bool, timeout_ms: Option<u64>) -> Option<String> {
self.detect_inner(preferred_url, force, timeout_ms, true).await
}
/// Probe only `preferred_url` without expanding to `KNOWN_PORTS` or the
/// `±SCAN_RADIUS` neighborhood. Exists for deterministic tests that need
/// to pin detection to a specific mock server; production callers should
/// use [`detect`].
pub async fn detect_exact(
&self,
preferred_url: Option<&str>,
force: bool,
timeout_ms: Option<u64>,
) -> Option<String> {
self.detect_inner(preferred_url, force, timeout_ms, false).await
}
async fn detect_inner(
&self,
preferred_url: Option<&str>,
force: bool,
timeout_ms: Option<u64>,
scan_neighbors: bool,
) -> Option<String> {
if !force {
let cache = self.cache.lock().await;
if let Some(ref c) = *cache {
let ttl = if c.was_hit { CACHE_HIT_TTL } else { CACHE_MISS_TTL };
if c.cached_at.elapsed() < ttl {
tracing::debug!(cached_url = ?c.url, "returning cached star-office result");
return c.url.clone();
}
}
}
let candidates = build_candidate_urls(preferred_url, scan_neighbors);
let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
tracing::debug!(count = candidates.len(), "scanning star-office candidate URLs");
let result = self.scan_candidates(&candidates, timeout).await;
let mut cache = self.cache.lock().await;
*cache = Some(DetectCache {
url: result.clone(),
cached_at: Instant::now(),
was_hit: result.is_some(),
});
result
}
async fn scan_candidates(&self, candidates: &[String], timeout: Duration) -> Option<String> {
for chunk in candidates.chunks(MAX_CONCURRENT_WORKERS) {
let mut set = tokio::task::JoinSet::new();
for url in chunk {
let client = self.client.clone();
let url = url.clone();
set.spawn(async move {
if check_health(&client, &url, timeout).await {
Some(url)
} else {
None
}
});
}
while let Some(result) = set.join_next().await {
if let Ok(Some(url)) = result {
return Some(url);
}
}
}
None
}
}
fn build_candidate_urls(preferred_url: Option<&str>, scan_neighbors: bool) -> Vec<String> {
let mut seed_ports: Vec<u16> = Vec::new();
if let Some(url) = preferred_url
&& let Some(port) = extract_port(url)
{
seed_ports.push(port);
}
if !scan_neighbors {
// Exact mode: skip KNOWN_PORTS and the ±SCAN_RADIUS expansion so the
// detector talks to the caller-supplied URL only.
return seed_ports.iter().map(|p| format!("http://localhost:{p}")).collect();
}
for p in KNOWN_PORTS {
if !seed_ports.contains(&p) {
seed_ports.push(p);
}
}
let mut expanded: Vec<u16> = Vec::new();
for &base in &seed_ports {
let start = base.saturating_sub(SCAN_RADIUS);
let end = base.saturating_add(SCAN_RADIUS);
for p in start..=end {
if p > 0 && !expanded.contains(&p) {
expanded.push(p);
}
}
}
let mut urls = Vec::with_capacity(expanded.len());
for &p in &seed_ports {
urls.push(format!("http://localhost:{p}"));
}
for &p in &expanded {
if !seed_ports.contains(&p) {
urls.push(format!("http://localhost:{p}"));
}
}
urls
}
fn extract_port(url: &str) -> Option<u16> {
let without_scheme = url.strip_prefix("http://").or_else(|| url.strip_prefix("https://"))?;
let host_part = without_scheme.split('/').next()?;
let port_str = host_part.rsplit(':').next()?;
port_str.parse().ok()
}
async fn check_health(client: &reqwest::Client, base_url: &str, timeout: Duration) -> bool {
let health_url = format!("{base_url}/health");
let resp = match client.get(&health_url).timeout(timeout).send().await {
Ok(r) => r,
Err(_) => return false,
};
if !resp.status().is_success() {
return false;
}
let status_url = format!("{base_url}/status");
let resp = match client.get(&status_url).timeout(timeout).send().await {
Ok(r) => r,
Err(_) => return false,
};
let body = match resp.text().await {
Ok(t) => t.to_lowercase(),
Err(_) => return false,
};
if !STATUS_MARKERS.iter().any(|m| body.contains(m)) {
return false;
}
let resp = match client.get(base_url).timeout(timeout).send().await {
Ok(r) => r,
Err(_) => return false,
};
let body = match resp.text().await {
Ok(t) => t.to_lowercase(),
Err(_) => return false,
};
FEATURE_KEYWORDS.iter().any(|k| body.contains(k)) && !EXCLUDE_KEYWORDS.iter().any(|k| body.contains(k))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_port_http() {
assert_eq!(extract_port("http://localhost:19000"), Some(19000));
}
#[test]
fn extract_port_https() {
assert_eq!(extract_port("https://localhost:8443"), Some(8443));
}
#[test]
fn extract_port_with_path() {
assert_eq!(extract_port("http://localhost:19000/star"), Some(19000));
}
#[test]
fn extract_port_no_scheme() {
assert_eq!(extract_port("localhost:19000"), None);
}
#[test]
fn extract_port_no_port() {
assert_eq!(extract_port("http://localhost"), None);
}
#[test]
fn extract_port_invalid_port() {
assert_eq!(extract_port("http://localhost:abc"), None);
}
#[test]
fn extract_port_ipv4() {
assert_eq!(extract_port("http://127.0.0.1:18791"), Some(18791));
}
#[test]
fn build_candidates_default_ports() {
let urls = build_candidate_urls(None, true);
assert!(urls[0] == "http://localhost:19000");
assert!(urls[1] == "http://localhost:18791");
let expected_count = count_unique_expanded_ports(&[19000, 18791]);
assert_eq!(urls.len(), expected_count);
}
#[test]
fn build_candidates_preferred_first() {
let urls = build_candidate_urls(Some("http://localhost:15000"), true);
assert_eq!(urls[0], "http://localhost:15000");
assert_eq!(urls[1], "http://localhost:19000");
assert_eq!(urls[2], "http://localhost:18791");
}
#[test]
fn build_candidates_preferred_overlaps_known() {
let urls = build_candidate_urls(Some("http://localhost:19000"), true);
assert_eq!(urls[0], "http://localhost:19000");
assert_eq!(urls[1], "http://localhost:18791");
}
#[test]
fn build_candidates_no_duplicates() {
let urls = build_candidate_urls(Some("http://localhost:18800"), true);
let unique: std::collections::HashSet<_> = urls.iter().collect();
assert_eq!(urls.len(), unique.len());
}
#[test]
fn build_candidates_scan_radius_coverage() {
let urls = build_candidate_urls(None, true);
assert!(urls.contains(&"http://localhost:18976".to_string()));
assert!(urls.contains(&"http://localhost:19024".to_string()));
assert!(urls.contains(&"http://localhost:18767".to_string()));
assert!(urls.contains(&"http://localhost:18815".to_string()));
}
#[test]
fn build_candidates_preferred_without_port_ignored() {
let urls = build_candidate_urls(Some("http://localhost"), true);
assert_eq!(urls[0], "http://localhost:19000");
}
#[test]
fn build_candidates_low_port_no_underflow() {
let urls = build_candidate_urls(Some("http://localhost:10"), true);
assert!(urls.iter().all(|u| {
let p = extract_port(u).unwrap();
p > 0
}));
}
#[test]
fn build_candidates_exact_mode_only_preferred() {
let urls = build_candidate_urls(Some("http://localhost:55555"), false);
assert_eq!(urls, vec!["http://localhost:55555"]);
}
#[test]
fn build_candidates_exact_mode_empty_when_no_preferred() {
assert!(build_candidate_urls(None, false).is_empty());
}
fn count_unique_expanded_ports(seeds: &[u16]) -> usize {
let mut all = std::collections::HashSet::new();
for &base in seeds {
let start = base.saturating_sub(SCAN_RADIUS);
let end = base.saturating_add(SCAN_RADIUS);
for p in start..=end {
if p > 0 {
all.insert(p);
}
}
}
all.len()
}
#[test]
fn cache_ttl_constants() {
assert_eq!(CACHE_HIT_TTL, Duration::from_secs(20));
assert_eq!(CACHE_MISS_TTL, Duration::from_millis(1500));
}
#[test]
fn default_timeout_constant() {
assert_eq!(DEFAULT_TIMEOUT_MS, 1000);
}
#[test]
fn max_concurrent_workers_constant() {
assert_eq!(MAX_CONCURRENT_WORKERS, 6);
}
#[test]
fn scan_radius_constant() {
assert_eq!(SCAN_RADIUS, 24);
}
#[test]
fn status_markers_all_present() {
let expected = ["idle", "writing", "researching", "executing", "syncing", "error"];
assert_eq!(STATUS_MARKERS, expected);
}
#[test]
fn feature_keywords_all_present() {
let expected = ["star office", "decorate room", "asset sidebar"];
assert_eq!(FEATURE_KEYWORDS, expected);
}
#[test]
fn exclude_keywords_contains_openclaw() {
assert_eq!(EXCLUDE_KEYWORDS, ["openclaw control"]);
}
#[tokio::test]
async fn detect_no_service_returns_none() {
let detector = StarOfficeDetector::new(reqwest::Client::new());
let result = detector
.detect_exact(Some("http://localhost:59999"), false, Some(50))
.await;
assert!(result.is_none());
}
#[tokio::test]
async fn detect_cache_miss_stored() {
let detector = StarOfficeDetector::new(reqwest::Client::new());
let _ = detector
.detect_exact(Some("http://localhost:59998"), false, Some(50))
.await;
let cache = detector.cache.lock().await;
let c = cache.as_ref().unwrap();
assert!(c.url.is_none());
assert!(!c.was_hit);
}
}
@@ -0,0 +1,18 @@
use std::path::PathBuf;
use std::sync::Arc;
use crate::conversion::ConversionService;
use crate::proxy::ProxyService;
use crate::snapshot::SnapshotService;
use crate::star_office::StarOfficeDetector;
use crate::watch_manager::OfficecliWatchManager;
#[derive(Clone)]
pub struct OfficeRouterState {
pub watch_manager: Arc<OfficecliWatchManager>,
pub snapshot_service: Arc<SnapshotService>,
pub star_office_detector: Arc<StarOfficeDetector>,
pub conversion_service: Arc<ConversionService>,
pub proxy_service: Arc<ProxyService>,
pub allowed_roots: Vec<PathBuf>,
}
@@ -0,0 +1,134 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DocType {
Word,
Excel,
Ppt,
}
impl DocType {
pub fn event_prefix(&self) -> &'static str {
match self {
Self::Word => "word-preview",
Self::Excel => "excel-preview",
Self::Ppt => "ppt-preview",
}
}
pub fn proxy_prefix(&self) -> &'static str {
match self {
Self::Word | Self::Excel => "office-watch-proxy",
Self::Ppt => "ppt-proxy",
}
}
pub fn officecli_subcommand(&self) -> &'static str {
"watch"
}
}
impl std::fmt::Display for DocType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Word => write!(f, "word"),
Self::Excel => write!(f, "excel"),
Self::Ppt => write!(f, "ppt"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficecliStatus {
Starting,
Installing,
Ready,
Error,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn doc_type_event_prefix() {
assert_eq!(DocType::Word.event_prefix(), "word-preview");
assert_eq!(DocType::Excel.event_prefix(), "excel-preview");
assert_eq!(DocType::Ppt.event_prefix(), "ppt-preview");
}
#[test]
fn doc_type_proxy_prefix() {
assert_eq!(DocType::Word.proxy_prefix(), "office-watch-proxy");
assert_eq!(DocType::Excel.proxy_prefix(), "office-watch-proxy");
assert_eq!(DocType::Ppt.proxy_prefix(), "ppt-proxy");
}
#[test]
fn doc_type_officecli_subcommand() {
assert_eq!(DocType::Word.officecli_subcommand(), "watch");
assert_eq!(DocType::Excel.officecli_subcommand(), "watch");
assert_eq!(DocType::Ppt.officecli_subcommand(), "watch");
}
#[test]
fn doc_type_display() {
assert_eq!(DocType::Word.to_string(), "word");
assert_eq!(DocType::Excel.to_string(), "excel");
assert_eq!(DocType::Ppt.to_string(), "ppt");
}
#[test]
fn doc_type_serialize() {
let cases = [
(DocType::Word, "\"word\""),
(DocType::Excel, "\"excel\""),
(DocType::Ppt, "\"ppt\""),
];
for (dt, expected) in cases {
assert_eq!(serde_json::to_string(&dt).unwrap(), expected);
}
}
#[test]
fn doc_type_deserialize() {
let cases = [
("\"word\"", DocType::Word),
("\"excel\"", DocType::Excel),
("\"ppt\"", DocType::Ppt),
];
for (input, expected) in cases {
let parsed: DocType = serde_json::from_str(input).unwrap();
assert_eq!(parsed, expected);
}
}
#[test]
fn doc_type_invalid_deserialize() {
assert!(serde_json::from_str::<DocType>("\"pdf\"").is_err());
}
#[test]
fn doc_type_eq_and_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(DocType::Word);
set.insert(DocType::Excel);
set.insert(DocType::Ppt);
assert_eq!(set.len(), 3);
assert!(set.contains(&DocType::Word));
}
#[test]
fn officecli_status_variants() {
let statuses = [
OfficecliStatus::Starting,
OfficecliStatus::Installing,
OfficecliStatus::Ready,
OfficecliStatus::Error,
];
assert_eq!(statuses.len(), 4);
assert_ne!(OfficecliStatus::Starting, OfficecliStatus::Ready);
}
}
@@ -0,0 +1,783 @@
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use nomifun_api_types::{PreviewState, PreviewStatusEvent, WebSocketMessage};
use nomifun_realtime::EventBroadcaster;
use nomifun_runtime::Builder as CmdBuilder;
use tokio::sync::Mutex;
use crate::error::OfficeError;
use crate::port::{allocate_port, is_port_listening};
use crate::types::DocType;
const POLL_INTERVAL_MS: u64 = 100;
const POLL_MAX_ATTEMPTS: u32 = 150;
const STOP_DELAY_MS: u64 = 500;
const VERSION_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
// ---------------------------------------------------------------------------
// ProcessSpawner trait — abstraction for child process management
// ---------------------------------------------------------------------------
#[async_trait::async_trait]
pub trait ProcessSpawner: Send + Sync {
async fn spawn_officecli(
&self,
file_path: &str,
port: u16,
doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError>;
async fn install_officecli(&self) -> Result<(), OfficeError>;
async fn is_officecli_installed(&self) -> bool;
async fn check_update(&self, doc_type: DocType) -> Result<(), OfficeError>;
}
pub trait ProcessHandle: Send + Sync {
fn kill(&self);
fn is_alive(&self) -> bool;
}
// ---------------------------------------------------------------------------
// WatchSession — per-file preview session
// ---------------------------------------------------------------------------
struct WatchSession {
port: u16,
process: Box<dyn ProcessHandle>,
file_path: String,
doc_type: DocType,
aborted: bool,
}
// ---------------------------------------------------------------------------
// OfficecliWatchManager
// ---------------------------------------------------------------------------
pub struct OfficecliWatchManager {
sessions: DashMap<String, WatchSession>,
spawner: Arc<dyn ProcessSpawner>,
broadcaster: Arc<dyn EventBroadcaster>,
last_version_check: Mutex<Option<std::time::Instant>>,
}
impl OfficecliWatchManager {
pub fn new(spawner: Arc<dyn ProcessSpawner>, broadcaster: Arc<dyn EventBroadcaster>) -> Self {
Self {
sessions: DashMap::new(),
spawner,
broadcaster,
last_version_check: Mutex::new(None),
}
}
pub async fn start(&self, file_path: &str, doc_type: DocType) -> Result<u16, OfficeError> {
let resolved = resolve_path(file_path)?;
let key = session_key(&resolved, doc_type);
if let Some(entry) = self.sessions.get(&key) {
if !entry.aborted && entry.process.is_alive() {
return Ok(entry.port);
}
drop(entry);
self.sessions.remove(&key);
}
self.broadcast_status(doc_type, PreviewState::Starting, None);
let result = self.try_start(&resolved, doc_type).await;
match &result {
Ok(port) => {
self.broadcast_status(doc_type, PreviewState::Ready, None);
if doc_type == DocType::Ppt {
self.maybe_check_update(doc_type).await;
}
Ok(*port)
}
Err(e) => {
self.broadcast_status(doc_type, PreviewState::Error, Some(e.to_string()));
Err(match e {
OfficeError::OfficecliNotFound => OfficeError::OfficecliNotFound,
OfficeError::InstallFailed(m) => OfficeError::InstallFailed(m.clone()),
OfficeError::StartFailed(m) => OfficeError::StartFailed(m.clone()),
OfficeError::PortTimeout(m) => OfficeError::PortTimeout(m.clone()),
OfficeError::Io(io) => OfficeError::StartFailed(format!("IO error: {io}")),
OfficeError::Snapshot(m) => OfficeError::StartFailed(m.clone()),
OfficeError::Json(e) => OfficeError::StartFailed(format!("JSON error: {e}")),
OfficeError::Conversion(m) => OfficeError::StartFailed(m.clone()),
OfficeError::ToolNotFound(m) => OfficeError::StartFailed(m.clone()),
})
}
}
}
async fn try_start(&self, resolved: &str, doc_type: DocType) -> Result<u16, OfficeError> {
let port = allocate_port()?;
let spawn_result = self.spawner.spawn_officecli(resolved, port, doc_type).await;
let process = match spawn_result {
Ok(p) => p,
Err(OfficeError::OfficecliNotFound) => {
self.broadcast_status(doc_type, PreviewState::Installing, None);
self.spawner.install_officecli().await?;
self.spawner.spawn_officecli(resolved, port, doc_type).await?
}
Err(e) => return Err(e),
};
self.poll_port_ready(port, resolved).await?;
let key = session_key(resolved, doc_type);
self.sessions.insert(
key,
WatchSession {
port,
process,
file_path: resolved.to_owned(),
doc_type,
aborted: false,
},
);
Ok(port)
}
async fn poll_port_ready(&self, port: u16, file_path: &str) -> Result<(), OfficeError> {
for _ in 0..POLL_MAX_ATTEMPTS {
if is_port_listening(port).await {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
Err(OfficeError::PortTimeout(file_path.to_owned()))
}
pub async fn stop(&self, file_path: &str, doc_type: DocType) {
let resolved = match resolve_path(file_path) {
Ok(p) => p,
Err(_) => return,
};
let key = session_key(&resolved, doc_type);
tokio::time::sleep(Duration::from_millis(STOP_DELAY_MS)).await;
if let Some((_, session)) = self.sessions.remove(&key) {
session.process.kill();
}
}
pub fn stop_all(&self) {
for entry in self.sessions.iter() {
tracing::debug!(
file_path = %entry.value().file_path,
doc_type = %entry.value().doc_type,
"stopping preview session"
);
entry.value().process.kill();
}
self.sessions.clear();
}
pub fn is_active_port(&self, port: u16, doc_type: DocType) -> bool {
self.sessions
.iter()
.any(|entry| entry.port == port && entry.doc_type == doc_type)
}
pub fn is_active_watch_port(&self, port: u16) -> bool {
self.sessions
.iter()
.any(|entry| entry.port == port && matches!(entry.doc_type, DocType::Word | DocType::Excel))
}
pub fn active_session_count(&self) -> usize {
self.sessions.len()
}
async fn maybe_check_update(&self, doc_type: DocType) {
let mut last = self.last_version_check.lock().await;
let should_check = match *last {
Some(t) => t.elapsed() >= VERSION_CHECK_INTERVAL,
None => true,
};
if should_check {
*last = Some(std::time::Instant::now());
drop(last);
let spawner = Arc::clone(&self.spawner);
tokio::spawn(async move {
if let Err(e) = spawner.check_update(doc_type).await {
tracing::warn!("officecli version check failed: {e}");
}
});
}
}
fn broadcast_status(&self, doc_type: DocType, state: PreviewState, message: Option<String>) {
let event_name = format!("{}.status", doc_type.event_prefix());
let payload = PreviewStatusEvent { state, message };
let data = match serde_json::to_value(payload) {
Ok(v) => v,
Err(e) => {
tracing::error!("failed to serialize preview status: {e}");
return;
}
};
self.broadcaster.broadcast(WebSocketMessage::new(event_name, data));
}
}
impl Drop for OfficecliWatchManager {
fn drop(&mut self) {
for entry in self.sessions.iter() {
entry.value().process.kill();
}
self.sessions.clear();
}
}
// ---------------------------------------------------------------------------
// DefaultProcessSpawner — real implementation using tokio::process
// ---------------------------------------------------------------------------
pub struct DefaultProcessSpawner;
struct TokioProcessHandle {
child: Mutex<Option<tokio::process::Child>>,
}
impl ProcessHandle for TokioProcessHandle {
fn kill(&self) {
if let Ok(mut guard) = self.child.try_lock() {
if let Some(ref mut child) = *guard {
let _ = child.start_kill();
}
*guard = None;
}
}
fn is_alive(&self) -> bool {
if let Ok(mut guard) = self.child.try_lock()
&& let Some(ref mut child) = *guard
{
return child.try_wait().ok().flatten().is_none();
}
false
}
}
#[async_trait::async_trait]
impl ProcessSpawner for DefaultProcessSpawner {
async fn spawn_officecli(
&self,
file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
let mut builder = CmdBuilder::new("officecli");
builder
.arg("watch")
.arg(file_path)
.arg("--port")
.arg(port.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let child = builder.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
OfficeError::OfficecliNotFound
} else {
OfficeError::StartFailed(e.to_string())
}
})?;
Ok(Box::new(TokioProcessHandle {
child: Mutex::new(Some(child)),
}))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
let mut builder = CmdBuilder::clean_cli("npm");
builder.args(["install", "-g", "officecli"]);
let output = builder
.output()
.await
.map_err(|e| OfficeError::InstallFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(OfficeError::InstallFailed(stderr.into_owned()));
}
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
let mut builder = CmdBuilder::clean_cli("officecli");
builder.arg("--version");
builder.output().await.is_ok_and(|o| o.status.success())
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
let mut builder = CmdBuilder::clean_cli("npm");
builder.args(["outdated", "-g", "officecli"]);
let output = builder
.output()
.await
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
if !output.status.success() {
tracing::info!("officecli update available, installing...");
let mut install_builder = CmdBuilder::clean_cli("npm");
install_builder.args(["install", "-g", "officecli@latest"]);
let install = install_builder
.output()
.await
.map_err(|e| OfficeError::InstallFailed(e.to_string()))?;
if !install.status.success() {
let stderr = String::from_utf8_lossy(&install.stderr);
tracing::warn!("officecli update failed: {stderr}");
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn resolve_path(file_path: &str) -> Result<String, OfficeError> {
let path = std::path::Path::new(file_path);
let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
Ok(resolved.to_string_lossy().into_owned())
}
fn session_key(resolved_path: &str, doc_type: DocType) -> String {
format!("{doc_type}:{resolved_path}")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
struct MockProcessHandle {
alive: AtomicBool,
killed: AtomicBool,
}
impl MockProcessHandle {
fn new() -> Self {
Self {
alive: AtomicBool::new(true),
killed: AtomicBool::new(false),
}
}
}
impl ProcessHandle for MockProcessHandle {
fn kill(&self) {
self.alive.store(false, Ordering::SeqCst);
self.killed.store(true, Ordering::SeqCst);
}
fn is_alive(&self) -> bool {
self.alive.load(Ordering::SeqCst)
}
}
struct MockSpawner {
installed: AtomicBool,
spawn_count: AtomicU32,
install_count: AtomicU32,
update_count: AtomicU32,
fail_spawn: AtomicBool,
start_listener: AtomicBool,
}
impl MockSpawner {
fn new() -> Self {
Self {
installed: AtomicBool::new(true),
spawn_count: AtomicU32::new(0),
install_count: AtomicU32::new(0),
update_count: AtomicU32::new(0),
fail_spawn: AtomicBool::new(false),
start_listener: AtomicBool::new(true),
}
}
}
#[async_trait::async_trait]
impl ProcessSpawner for MockSpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
self.spawn_count.fetch_add(1, Ordering::SeqCst);
if self.fail_spawn.load(Ordering::SeqCst) {
return Err(OfficeError::StartFailed("mock spawn failure".into()));
}
if !self.installed.load(Ordering::SeqCst) {
return Err(OfficeError::OfficecliNotFound);
}
if self.start_listener.load(Ordering::SeqCst) {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
std::mem::forget(listener);
}
Ok(Box::new(MockProcessHandle::new()))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
self.install_count.fetch_add(1, Ordering::SeqCst);
self.installed.store(true, Ordering::SeqCst);
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
self.installed.load(Ordering::SeqCst)
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
self.update_count.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(Vec::new()),
}
}
fn events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
self.events.lock().unwrap().clone()
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
fn make_manager(spawner: Arc<MockSpawner>, broadcaster: Arc<RecordingBroadcaster>) -> OfficecliWatchManager {
OfficecliWatchManager::new(spawner, broadcaster)
}
#[test]
fn session_key_format() {
let key = session_key("/path/to/doc.docx", DocType::Word);
assert_eq!(key, "word:/path/to/doc.docx");
}
#[test]
fn session_key_different_doc_types() {
let k1 = session_key("/a.docx", DocType::Word);
let k2 = session_key("/a.docx", DocType::Excel);
assert_ne!(k1, k2);
}
#[tokio::test]
async fn start_creates_session() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
assert!(port > 0);
assert_eq!(mgr.active_session_count(), 1);
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn start_reuses_existing_session() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let p1 = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
let p2 = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
assert_eq!(p1, p2);
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn start_different_doc_types_independent() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let p1 = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
let p2 = mgr.start(file.to_str().unwrap(), DocType::Excel).await.unwrap();
assert_ne!(p1, p2);
assert_eq!(mgr.active_session_count(), 2);
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn stop_removes_session() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let path = file.to_str().unwrap();
mgr.start(path, DocType::Word).await.unwrap();
assert_eq!(mgr.active_session_count(), 1);
mgr.stop(path, DocType::Word).await;
assert_eq!(mgr.active_session_count(), 0);
}
#[tokio::test]
async fn stop_all_clears_everything() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let f1 = dir.path().join("a.docx");
let f2 = dir.path().join("b.xlsx");
std::fs::write(&f1, b"a").unwrap();
std::fs::write(&f2, b"b").unwrap();
mgr.start(f1.to_str().unwrap(), DocType::Word).await.unwrap();
mgr.start(f2.to_str().unwrap(), DocType::Excel).await.unwrap();
assert_eq!(mgr.active_session_count(), 2);
mgr.stop_all();
assert_eq!(mgr.active_session_count(), 0);
}
#[tokio::test]
async fn is_active_port_returns_true_for_active() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
assert!(mgr.is_active_port(port, DocType::Word));
assert!(!mgr.is_active_port(port, DocType::Ppt));
assert!(!mgr.is_active_port(12345, DocType::Word));
}
#[tokio::test]
async fn is_active_watch_port_accepts_word_and_excel() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let word_file = dir.path().join("test.docx");
let excel_file = dir.path().join("test.xlsx");
let ppt_file = dir.path().join("test.pptx");
std::fs::write(&word_file, b"w").unwrap();
std::fs::write(&excel_file, b"e").unwrap();
std::fs::write(&ppt_file, b"p").unwrap();
let word_port = mgr.start(word_file.to_str().unwrap(), DocType::Word).await.unwrap();
let excel_port = mgr.start(excel_file.to_str().unwrap(), DocType::Excel).await.unwrap();
let ppt_port = mgr.start(ppt_file.to_str().unwrap(), DocType::Ppt).await.unwrap();
assert!(mgr.is_active_watch_port(word_port));
assert!(mgr.is_active_watch_port(excel_port));
assert!(!mgr.is_active_watch_port(ppt_port));
assert!(!mgr.is_active_watch_port(12345));
}
#[tokio::test]
async fn auto_install_when_not_found() {
let spawner = Arc::new(MockSpawner::new());
spawner.installed.store(false, Ordering::SeqCst);
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
assert!(port > 0);
assert_eq!(spawner.install_count.load(Ordering::SeqCst), 1);
// First spawn fails (not installed), then install, then second spawn succeeds
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn broadcasts_starting_and_ready() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
let events = broadcaster.events();
assert!(events.len() >= 2);
assert_eq!(events[0].name, "word-preview.status");
assert_eq!(events[0].data["state"], "starting");
assert_eq!(events[1].name, "word-preview.status");
assert_eq!(events[1].data["state"], "ready");
}
#[tokio::test]
async fn broadcasts_installing_on_auto_install() {
let spawner = Arc::new(MockSpawner::new());
spawner.installed.store(false, Ordering::SeqCst);
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
let events = broadcaster.events();
let states: Vec<&str> = events.iter().filter_map(|e| e.data["state"].as_str()).collect();
assert!(states.contains(&"starting"));
assert!(states.contains(&"installing"));
assert!(states.contains(&"ready"));
}
#[tokio::test]
async fn broadcasts_error_on_failure() {
let spawner = Arc::new(MockSpawner::new());
spawner.fail_spawn.store(true, Ordering::SeqCst);
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let result = mgr.start(file.to_str().unwrap(), DocType::Word).await;
assert!(result.is_err());
let events = broadcaster.events();
let last = events.last().unwrap();
assert_eq!(last.data["state"], "error");
}
#[tokio::test(start_paused = true)]
async fn port_timeout_on_no_listener() {
let spawner = Arc::new(MockSpawner::new());
spawner.start_listener.store(false, Ordering::SeqCst);
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let resolved = resolve_path(file.to_str().unwrap()).unwrap();
let port = allocate_port().unwrap();
let result = mgr.poll_port_ready(port, &resolved).await;
assert!(matches!(result, Err(OfficeError::PortTimeout(_))));
}
#[tokio::test]
async fn ppt_triggers_version_check() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.pptx");
std::fs::write(&file, b"test").unwrap();
mgr.start(file.to_str().unwrap(), DocType::Ppt).await.unwrap();
// Give the spawned task a moment
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(spawner.update_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn word_does_not_trigger_version_check() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(spawner.update_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn stop_nonexistent_is_no_op() {
let spawner = Arc::new(MockSpawner::new());
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(spawner, broadcaster);
mgr.stop("/nonexistent/file.docx", DocType::Word).await;
assert_eq!(mgr.active_session_count(), 0);
}
#[test]
fn resolve_path_normalizes() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let resolved = resolve_path(file.to_str().unwrap()).unwrap();
assert!(!resolved.is_empty());
}
#[test]
fn resolve_path_nonexistent_returns_original() {
let result = resolve_path("/nonexistent/path/test.docx").unwrap();
assert_eq!(result, "/nonexistent/path/test.docx");
}
}