use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct FsQuery {
pub path: String,
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct FileReadQuery {
pub path: String,
#[serde(default)]
pub start_line: Option<u64>,
#[serde(default)]
pub end_line: Option<u64>,
}
impl FileReadQuery {
pub fn line_window(&self) -> Result<Option<(u64, Option<u64>)>, String> {
if self.start_line.is_none() && self.end_line.is_none() {
return Ok(None);
}
let start = self.start_line.unwrap_or(1);
if start == 0 {
return Err(
"start_line is 1-based, so 0 is not a line. Line 1 is the first line; a caller \
working from 0-based offsets wants start_line=1."
.to_string(),
);
}
if let Some(end) = self.end_line
&& end < start
{
return Err(format!(
"end_line {end} is before start_line {start}. Both bounds are 1-based and \
inclusive, so end_line must be at least start_line. An end_line past the last \
line is fine and reads through EOF; this refusal is for an inverted range, \
which no file can satisfy."
));
}
Ok(Some((start, self.end_line)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_fs_query_without_a_mode_deserializes_and_keeps_the_mode_a_string() {
let query: FsQuery = serde_json::from_str(r#"{"path":"/tmp/f"}"#).expect("deserializes");
assert_eq!(query.path, "/tmp/f");
assert!(query.mode.is_none());
let with_mode: FsQuery =
serde_json::from_str(r#"{"path":"/tmp/f","mode":"0644"}"#).expect("deserializes");
assert_eq!(with_mode.mode.as_deref(), Some("0644"));
}
#[test]
fn a_file_read_query_without_a_range_leaves_both_bounds_absent() {
let query: FileReadQuery =
serde_json::from_str(r#"{"path":"/tmp/f"}"#).expect("deserializes");
assert_eq!(query.path, "/tmp/f");
assert!(query.start_line.is_none());
assert!(query.end_line.is_none());
let ranged: FileReadQuery =
serde_json::from_str(r#"{"path":"/tmp/f","start_line":3,"end_line":9}"#)
.expect("deserializes");
assert_eq!(ranged.start_line, Some(3));
assert_eq!(ranged.end_line, Some(9));
}
fn window(
start_line: Option<u64>,
end_line: Option<u64>,
) -> Result<Option<(u64, Option<u64>)>, String> {
FileReadQuery {
path: "/tmp/f".into(),
start_line,
end_line,
}
.line_window()
}
#[test]
fn a_line_window_defaults_each_absent_bound() {
assert_eq!(window(None, None), Ok(None));
assert_eq!(window(Some(4), None), Ok(Some((4, None))));
assert_eq!(window(None, Some(2)), Ok(Some((1, Some(2)))));
assert_eq!(window(Some(2), Some(4)), Ok(Some((2, Some(4)))));
assert_eq!(window(Some(3), Some(3)), Ok(Some((3, Some(3)))));
assert_eq!(
window(Some(1), Some(u64::MAX)),
Ok(Some((1, Some(u64::MAX))))
);
}
#[test]
fn a_line_window_refuses_line_zero_and_an_inverted_range() {
let zero = window(Some(0), Some(4)).expect_err("line 0");
assert!(zero.contains("start_line is 1-based"), "{zero}");
let inverted = window(Some(5), Some(2)).expect_err("inverted");
assert!(
inverted.contains("end_line 2 is before start_line 5"),
"{inverted}"
);
let implied = window(None, Some(0)).expect_err("end before the default start");
assert!(
implied.contains("end_line 0 is before start_line 1"),
"{implied}"
);
}
}