use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use crate::domain::error::{WireError, WireResult};
use crate::infrastructure::wire_uri::WireUri;
use async_trait::async_trait;
pub const TAIL_N_MAX: usize = 1000;
#[async_trait]
pub trait Adapter: Send + Sync {
fn scheme(&self) -> &'static str;
async fn fetch(&self, uri: &WireUri) -> WireResult<serde_json::Value>;
}
pub struct FileAdapter;
#[derive(Debug, Clone, PartialEq, Eq)]
enum TailMode {
Full,
LastSection,
LastN(usize),
}
impl FileAdapter {
pub async fn fetch_file(&self, raw_path: &str) -> WireResult<serde_json::Value> {
self.fetch_file_impl(raw_path, TailMode::Full).await
}
async fn fetch_file_impl(
&self,
raw_path: &str,
mode: TailMode,
) -> WireResult<serde_json::Value> {
let resolved = resolve_file_path(raw_path)?;
if !resolved.exists() {
return Ok(serde_json::json!({
"scheme": "file",
"kind": "file",
"path": resolved.display().to_string(),
"body": serde_json::Value::Null,
"metadata": serde_json::Value::Null,
}));
}
let meta = std::fs::metadata(&resolved)
.map_err(|e| WireError::Storage(format!("file adapter: stat: {e}")))?;
if meta.is_dir() {
let newest = newest_child(&resolved)?;
let body_full = std::fs::read_to_string(&newest)
.map_err(|e| WireError::Storage(format!("file adapter: read: {e}")))?;
let child_meta = std::fs::metadata(&newest)
.map_err(|e| WireError::Storage(format!("file adapter: stat child: {e}")))?;
let size_bytes = child_meta.len();
let modified_at = mtime_unix(&child_meta);
let meta_json = build_file_metadata(&newest);
let body = apply_tail(&body_full, &mode);
Ok(serde_json::json!({
"scheme": "file",
"kind": "newest_in_dir",
"dir": resolved.display().to_string(),
"path": newest.display().to_string(),
"body": body,
"size_bytes": size_bytes,
"modified_at": modified_at,
"metadata": meta_json,
}))
} else {
let body_full = std::fs::read_to_string(&resolved)
.map_err(|e| WireError::Storage(format!("file adapter: read: {e}")))?;
let size_bytes = meta.len();
let modified_at = mtime_unix(&meta);
let meta_json = build_file_metadata(&resolved);
let body = apply_tail(&body_full, &mode);
Ok(serde_json::json!({
"scheme": "file",
"kind": "file",
"path": resolved.display().to_string(),
"body": body,
"size_bytes": size_bytes,
"modified_at": modified_at,
"metadata": meta_json,
}))
}
}
}
#[async_trait]
impl Adapter for FileAdapter {
fn scheme(&self) -> &'static str {
"file"
}
async fn fetch(&self, uri: &WireUri) -> WireResult<serde_json::Value> {
let source_uri = uri.as_raw();
let rest = source_uri
.strip_prefix("file://")
.or_else(|| source_uri.strip_prefix("file:"))
.ok_or_else(|| WireError::Storage(format!("file adapter: bad uri: {source_uri}")))?;
let mode = parse_tail_mode(uri);
self.fetch_file_impl(rest, mode).await
}
}
fn parse_tail_mode(uri: &WireUri) -> TailMode {
if let Some(tail) = uri.query_get("tail") {
if tail == "last_section" {
return TailMode::LastSection;
}
return TailMode::Full;
}
if let Some(n_str) = uri.query_get("tail_n") {
if let Ok(n) = n_str.parse::<usize>() {
if n > 0 {
return TailMode::LastN(n.min(TAIL_N_MAX));
}
}
return TailMode::Full;
}
TailMode::Full
}
fn apply_tail(body: &str, mode: &TailMode) -> String {
match mode {
TailMode::Full => body.to_string(),
TailMode::LastSection => {
let pos = last_h2_pos(body);
body[pos..].to_string()
}
TailMode::LastN(n) => {
let lines: Vec<&str> = body.lines().collect();
let skip = lines.len().saturating_sub(*n);
lines[skip..].join("\n")
}
}
}
fn last_h2_pos(body: &str) -> usize {
let needle = "\n## ";
if let Some(pos) = body.rfind(needle) {
return pos + 1;
}
if body.starts_with("## ") {
return 0;
}
0
}
fn mtime_unix(meta: &std::fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn build_file_metadata(path: &std::path::Path) -> serde_json::Value {
match std::fs::metadata(path) {
Ok(meta) => {
let size_bytes = meta.len();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let full_path = path.display().to_string();
let last_modified: Option<u64> = meta.modified().ok().and_then(|mtime| {
mtime
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
});
let age_days: Option<u64> = meta.modified().ok().and_then(|mtime| {
std::time::SystemTime::now()
.duration_since(mtime)
.ok()
.map(|d| d.as_secs() / 86400)
});
serde_json::json!({
"filename": filename,
"full_path": full_path,
"last_modified": last_modified,
"size_bytes": size_bytes,
"age_days": age_days,
})
}
Err(_) => serde_json::Value::Null,
}
}
fn resolve_file_path(raw: &str) -> WireResult<PathBuf> {
let stripped = raw.split('#').next().unwrap_or(raw);
let stripped = stripped.split('?').next().unwrap_or(stripped);
let expanded = if let Some(rest) = stripped.strip_prefix("~/") {
let home = std::env::var("HOME")
.map_err(|_| WireError::Storage("file adapter: HOME unset".to_string()))?;
PathBuf::from(home).join(rest)
} else {
PathBuf::from(stripped)
};
Ok(expanded)
}
fn newest_child(dir: &std::path::Path) -> WireResult<PathBuf> {
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| WireError::Storage(format!("file adapter: read_dir: {e}")))?
.filter_map(|r| r.ok())
.filter(|e| e.path().is_file())
.collect();
if entries.is_empty() {
return Err(WireError::Storage(format!(
"file adapter: empty dir: {}",
dir.display()
)));
}
entries.sort_by_key(|e| {
e.metadata()
.and_then(|m| m.modified())
.ok()
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
Ok(entries
.last()
.map(|e| e.path())
.expect("non-empty sorted entries"))
}
#[cfg(test)]
mod tests {
use super::*;
fn write_test_file(name: &str, content: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("pw_adapter_test_{name}"));
std::fs::write(&path, content).expect("write temp file");
path
}
#[tokio::test]
async fn file_adapter_reads_existing_file() {
let me = file!();
let abs = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join(me);
let uri = WireUri::parse(&format!("file://{}", abs.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
let body = v["body"].as_str().unwrap();
assert!(body.contains("Layer 6 Adapter"));
}
#[tokio::test]
async fn file_adapter_rejects_non_file_uri() {
let a = FileAdapter;
let uri = WireUri::parse("ssh://nope/x").unwrap();
let r = a.fetch(&uri).await;
assert!(r.is_err());
}
#[tokio::test]
async fn file_adapter_r4_metadata_size_and_mtime() {
let content = "hello r4 metadata\n";
let path = write_test_file("r4_meta.txt", content);
let uri = WireUri::parse(&format!("file://{}", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(v["body"].as_str().unwrap(), content, "body unchanged");
assert!(
v["size_bytes"].as_u64().unwrap() > 0,
"size_bytes present and > 0"
);
assert!(
v["modified_at"].as_u64().is_some(),
"modified_at present as u64"
);
}
#[tokio::test]
async fn file_adapter_r5_tail_last_section() {
let content =
"# Title\n\nIntro text.\n\n## Section 1\n\nContent 1.\n\n## Section 2\n\nContent 2.\n";
let path = write_test_file("r5_last_section.txt", content);
let uri = WireUri::parse(&format!("file://{}?tail=last_section", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
let body = v["body"].as_str().unwrap();
assert!(
body.starts_with("## Section 2"),
"should start with last h2; got: {body}"
);
assert!(
!body.contains("Section 1"),
"should not contain earlier section; got: {body}"
);
}
#[tokio::test]
async fn file_adapter_r5_tail_n() {
let content = "line1\nline2\nline3\nline4\nline5\n";
let path = write_test_file("r5_tail_n.txt", content);
let uri = WireUri::parse(&format!("file://{}?tail_n=3", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
let body = v["body"].as_str().unwrap();
assert_eq!(body, "line3\nline4\nline5", "last 3 lines");
}
#[tokio::test]
async fn file_adapter_r5_tail_n_clamp() {
let content = "a1\na2\na3\na4\na5\n";
let path = write_test_file("r5_clamp.txt", content);
let uri = WireUri::parse(&format!("file://{}?tail_n=2000", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
let body = v["body"].as_str().unwrap();
assert!(
body.contains("a1"),
"clamp: all lines returned; got: {body}"
);
assert!(
body.contains("a5"),
"clamp: all lines returned; got: {body}"
);
}
#[tokio::test]
async fn file_adapter_r5_no_params_backward_compat() {
let content = "full content here\n";
let path = write_test_file("r5_no_params.txt", content);
let uri = WireUri::parse(&format!("file://{}", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(v["body"].as_str().unwrap(), content, "full body returned");
assert_eq!(v["scheme"].as_str().unwrap(), "file");
assert_eq!(v["kind"].as_str().unwrap(), "file");
}
#[tokio::test]
async fn file_adapter_r5_tail_invalid_graceful_fail() {
let content = "some content\n";
let path = write_test_file("r5_tail_inv.txt", content);
let uri = WireUri::parse(&format!("file://{}?tail=invalid", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(
v["body"].as_str().unwrap(),
content,
"graceful fail: full body returned"
);
}
#[tokio::test]
async fn file_adapter_r5_tail_n_invalid_graceful_fail() {
let content = "some content\n";
let path = write_test_file("r5_tail_n_inv.txt", content);
let uri = WireUri::parse(&format!("file://{}?tail_n=abc", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(
v["body"].as_str().unwrap(),
content,
"graceful fail: full body returned"
);
}
#[tokio::test]
async fn file_adapter_r5_r4_combined() {
let content = "# Header\n\n## Section 1\n\nContent 1.\n\n## Section 2\n\nContent 2.\n";
let full_size = content.len() as u64;
let path = write_test_file("r5_r4_combo.txt", content);
let uri = WireUri::parse(&format!("file://{}?tail=last_section", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
let body = v["body"].as_str().unwrap();
assert!(
body.starts_with("## Section 2"),
"R5: last section; got: {body}"
);
assert!(!body.contains("Section 1"), "R5: no earlier section");
assert_eq!(
v["size_bytes"].as_u64().unwrap(),
full_size,
"R4: size_bytes = full file size"
);
assert!(
v["modified_at"].as_u64().is_some(),
"R4: modified_at present"
);
}
#[test]
fn last_h2_pos_finds_last_section() {
let body = "# Title\n\n## S1\n\nContent\n\n## S2\n\nEnd\n";
let pos = last_h2_pos(body);
assert!(body[pos..].starts_with("## S2"), "pos={pos}");
}
#[test]
fn last_h2_pos_no_h2_returns_zero() {
let body = "No heading here\n";
assert_eq!(last_h2_pos(body), 0);
}
#[test]
fn last_h2_pos_h2_at_start() {
let body = "## Only\n\nContent\n";
assert_eq!(last_h2_pos(body), 0);
}
#[test]
fn apply_tail_full_returns_body() {
let body = "a\nb\nc\n";
assert_eq!(apply_tail(body, &TailMode::Full), body);
}
#[test]
fn apply_tail_last_n_returns_last_lines() {
let body = "a\nb\nc\nd\ne\n";
let result = apply_tail(body, &TailMode::LastN(3));
assert_eq!(result, "c\nd\ne");
}
#[test]
fn apply_tail_last_n_returns_all_when_n_exceeds_line_count() {
let body = "x\ny\n";
let result = apply_tail(body, &TailMode::LastN(1000));
assert_eq!(result, "x\ny");
}
#[test]
fn parse_tail_mode_unknown_tail_returns_full() {
let uri = WireUri::parse("file:///tmp/x?tail=unknown").unwrap();
assert_eq!(parse_tail_mode(&uri), TailMode::Full);
}
#[test]
fn parse_tail_mode_last_section() {
let uri = WireUri::parse("file:///tmp/x?tail=last_section").unwrap();
assert_eq!(parse_tail_mode(&uri), TailMode::LastSection);
}
#[test]
fn parse_tail_mode_tail_n_clamped() {
let uri = WireUri::parse("file:///tmp/x?tail_n=5000").unwrap();
assert_eq!(parse_tail_mode(&uri), TailMode::LastN(TAIL_N_MAX));
}
#[test]
fn parse_tail_mode_tail_n_abc_returns_full() {
let uri = WireUri::parse("file:///tmp/x?tail_n=abc").unwrap();
assert_eq!(parse_tail_mode(&uri), TailMode::Full);
}
#[test]
fn parse_tail_mode_no_params_returns_full() {
let uri = WireUri::parse("file:///tmp/x").unwrap();
assert_eq!(parse_tail_mode(&uri), TailMode::Full);
}
#[tokio::test]
async fn r4_metadata_present_for_existing_file() {
let me = file!();
let abs = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join(me);
let a = FileAdapter;
let v = a.fetch_file(&abs.display().to_string()).await.unwrap();
let meta = &v["metadata"];
assert!(
!meta.is_null(),
"metadata should be present for an existing file"
);
assert!(meta["filename"].is_string(), "filename should be a string");
assert!(
meta["full_path"].is_string(),
"full_path should be a string"
);
assert!(
meta["size_bytes"].is_number(),
"size_bytes should be a number"
);
assert!(meta.get("age_days").is_some(), "age_days key should exist");
}
#[tokio::test]
async fn r4_metadata_null_for_nonexistent_file() {
let a = FileAdapter;
let v = a
.fetch_file("/tmp/__persona_wire_nonexistent_r4_test_file__")
.await
.unwrap();
assert!(
v["body"].is_null(),
"body should be null for a non-existent file"
);
assert!(
v["metadata"].is_null(),
"metadata should be null for a non-existent file"
);
}
#[tokio::test]
async fn r4_body_backward_compat() {
let me = file!();
let abs = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join(me);
let a = FileAdapter;
let v = a.fetch_file(&abs.display().to_string()).await.unwrap();
assert!(
v["body"].is_string(),
"body should remain a string for an existing file"
);
assert!(
v["body"].as_str().unwrap().contains("Layer 6 Adapter"),
"body should contain expected file content"
);
}
#[tokio::test]
async fn r4_metadata_field_types() {
let me = file!();
let abs = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join(me);
let a = FileAdapter;
let v = a.fetch_file(&abs.display().to_string()).await.unwrap();
let meta = &v["metadata"];
let filename = meta["filename"].as_str().unwrap();
assert_eq!(filename, "adapter.rs", "filename should be the basename");
let full_path = meta["full_path"].as_str().unwrap();
assert!(
full_path.ends_with("adapter.rs"),
"full_path should end with adapter.rs"
);
assert!(
full_path.starts_with('/'),
"full_path should be an absolute path"
);
}
}