use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use crate::domain::error::{WireError, WireResult};
use crate::infrastructure::filter::{FilterCap, TailSpec, WireFilters};
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;
fn filter_caps(&self) -> &'static [FilterCap] {
&[]
}
async fn fetch(&self, uri: &WireUri) -> WireResult<serde_json::Value>;
}
pub struct FileAdapter;
impl FileAdapter {
pub async fn fetch_file(&self, raw_path: &str) -> WireResult<serde_json::Value> {
self.fetch_file_impl(raw_path, &WireFilters::default())
.await
}
async fn fetch_file_impl(
&self,
raw_path: &str,
filters: &WireFilters,
) -> 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_filters(&body_full, filters);
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_filters(&body_full, filters);
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"
}
fn filter_caps(&self) -> &'static [FilterCap] {
&[FilterCap::LineRange, FilterCap::Tail { n_max: TAIL_N_MAX }]
}
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 filters = WireFilters::parse(uri, self.filter_caps())?;
if filters.line_range.is_some() && filters.tail.is_some() {
return Err(WireError::Storage(
"lines and tail are mutually exclusive".to_string(),
));
}
self.fetch_file_impl(rest, &filters).await
}
}
fn apply_filters(body: &str, filters: &WireFilters) -> String {
if let Some((from, to)) = filters.line_range {
apply_lines(body, from, to)
} else {
apply_tail(body, filters.tail.as_ref())
}
}
fn apply_tail(body: &str, tail: Option<&TailSpec>) -> String {
match tail {
None => body.to_string(),
Some(TailSpec::LastSection) => {
let pos = last_h2_pos(body);
body[pos..].to_string()
}
Some(TailSpec::LastN(n)) => {
let lines: Vec<&str> = body.lines().collect();
let skip = lines.len().saturating_sub(*n);
lines[skip..].join("\n")
}
}
}
fn apply_lines(body: &str, from: usize, to: usize) -> String {
let lines: Vec<&str> = body.lines().collect();
let total = lines.len();
if from > total {
return String::new();
}
let start = from - 1;
let end = to.min(total);
lines[start..end].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_fails_loud() {
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 r = a.fetch(&uri).await;
assert!(r.is_err(), "unknown ?tail= value should fail loud");
}
#[tokio::test]
async fn file_adapter_r5_tail_n_invalid_fails_loud() {
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 r = a.fetch(&uri).await;
assert!(r.is_err(), "non-numeric ?tail_n= value should fail loud");
}
#[tokio::test]
async fn file_adapter_lines_normal_range() {
let content = "line1\nline2\nline3\nline4\nline5\n";
let path = write_test_file("lines_normal.txt", content);
let uri = WireUri::parse(&format!("file://{}?lines=2-4", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(v["body"].as_str().unwrap(), "line2\nline3\nline4");
}
#[tokio::test]
async fn file_adapter_lines_to_beyond_total_clamps_gracefully() {
let content = "line1\nline2\nline3\n";
let path = write_test_file("lines_over.txt", content);
let uri = WireUri::parse(&format!("file://{}?lines=2-100", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(v["body"].as_str().unwrap(), "line2\nline3");
}
#[tokio::test]
async fn file_adapter_lines_from_beyond_total_returns_empty() {
let content = "line1\nline2\n";
let path = write_test_file("lines_from_over.txt", content);
let uri = WireUri::parse(&format!("file://{}?lines=10-20", path.display())).unwrap();
let a = FileAdapter;
let v = a.fetch(&uri).await.unwrap();
assert_eq!(v["body"].as_str().unwrap(), "");
}
#[tokio::test]
async fn file_adapter_lines_and_tail_n_mutually_exclusive() {
let content = "line1\nline2\nline3\n";
let path = write_test_file("lines_and_tail.txt", content);
let uri = WireUri::parse(&format!("file://{}?lines=1-2&tail_n=1", path.display())).unwrap();
let a = FileAdapter;
let r = a.fetch(&uri).await;
assert!(
r.is_err(),
"lines and tail_n together should fail loud (mutually exclusive)"
);
}
#[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_none_returns_body() {
let body = "a\nb\nc\n";
assert_eq!(apply_tail(body, None), body);
}
#[test]
fn apply_tail_last_n_returns_last_lines() {
let body = "a\nb\nc\nd\ne\n";
let result = apply_tail(body, Some(&TailSpec::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, Some(&TailSpec::LastN(1000)));
assert_eq!(result, "x\ny");
}
#[test]
fn apply_lines_normal_range() {
let body = "a\nb\nc\nd\ne\n";
assert_eq!(apply_lines(body, 2, 4), "b\nc\nd");
}
#[test]
fn apply_lines_to_beyond_total_clamps() {
let body = "a\nb\nc\n";
assert_eq!(apply_lines(body, 1, 100), "a\nb\nc");
}
#[test]
fn apply_lines_from_beyond_total_returns_empty() {
let body = "a\nb\n";
assert_eq!(apply_lines(body, 10, 20), "");
}
#[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"
);
}
}