use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use qframe::storage::atomic_write;
use super::workspace::{READ_CAP, Workspace, io_problem, not_regular, read_capped};
use super::{Preview, diff};
const CONTEXT: usize = 3;
pub(super) struct Plan {
real: PathBuf,
shown: String,
before: String,
after: String,
existed: bool,
replaced: Option<usize>,
}
impl Plan {
pub(super) fn preview(&self) -> Preview {
Preview::Diff { path: self.shown.clone(), lines: diff::diff(&self.before, &self.after, CONTEXT) }
}
}
pub(super) fn plan(workspace: &Workspace, path: &str, old_text: &str, new_text: &str) -> Result<Plan, String> {
if path.trim().is_empty() || path.trim() == "." {
return Err("`edit_file` needs the path of a file.".to_owned());
}
if !old_text.is_empty() && old_text == new_text {
return Err("`old_text` and `new_text` are the same; there is nothing to change.".to_owned());
}
let real = workspace.resolve(path)?;
let shown = workspace.relative(&real);
if real.is_dir() {
return Err(format!("`{shown}` is a folder, not a file."));
}
let current = match read_capped(&real) {
Ok(Some(contents)) if contents.whole() => Some(contents.bytes),
Ok(Some(contents)) => {
return Err(format!(
"`{shown}` is {} bytes, larger than the {} MB qcli edits in one piece; change it with run_command instead.",
contents.size,
READ_CAP / (1024 * 1024)
));
}
Ok(None) => return Err(not_regular(&shown)),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(io_problem(&shown, &error)),
};
let Some(bytes) = current else {
if !old_text.is_empty() {
return Err(format!("`{shown}` does not exist; to create it, leave `old_text` empty."));
}
return Ok(Plan {
real,
shown,
before: String::new(),
after: new_text.to_owned(),
existed: false,
replaced: None,
});
};
let before = String::from_utf8(bytes).map_err(|_| format!("`{shown}` is not a text file; it cannot be edited."))?;
if old_text.is_empty() {
if !before.is_empty() {
return Err(format!("`{shown}` already exists and is not empty; give the text to replace in `old_text`."));
}
return Ok(Plan { real, shown, before, after: new_text.to_owned(), existed: true, replaced: None });
}
match occurrences(&before, old_text) {
0 => Err(format!(
"`old_text` does not occur in `{shown}`; read the file and copy the text exactly, including spaces and indentation."
)),
1 => {
let after = before.replacen(old_text, new_text, 1);
Ok(Plan { real, shown, before, after, existed: true, replaced: Some(count_lines(old_text)) })
}
many => Err(format!(
"`old_text` occurs {many} times in `{shown}`; include more of the surrounding lines so it matches exactly once."
)),
}
}
pub(super) fn edit(workspace: &Workspace, path: &str, old_text: &str, new_text: &str) -> Result<String, String> {
let plan = plan(workspace, path, old_text, new_text)?;
let shown = &plan.shown;
if plan.existed {
atomic_write(&plan.real, plan.after.as_bytes()).map_err(|error| io_problem(shown, &error))?;
} else {
write_new(&plan, shown)?;
}
let added = lines_word(count_lines(new_text));
Ok(match plan.replaced {
Some(removed) => format!("Replaced {} with {added} in `{shown}`.", lines_word(removed)),
None if plan.existed => format!("Wrote {added} to the empty file `{shown}`."),
None => format!("Created `{shown}` with {added}."),
})
}
fn write_new(plan: &Plan, shown: &str) -> Result<(), String> {
let Some(parent) = plan.real.parent() else {
return atomic_write(&plan.real, plan.after.as_bytes()).map_err(|error| io_problem(shown, &error));
};
let made = missing_folders(parent);
fs::create_dir_all(parent).map_err(|error| io_problem(shown, &error))?;
if let Err(error) = atomic_write(&plan.real, plan.after.as_bytes()) {
for folder in made.iter().rev() {
let _ = fs::remove_dir(folder);
}
return Err(io_problem(shown, &error));
}
Ok(())
}
fn missing_folders(path: &Path) -> Vec<PathBuf> {
let mut missing = Vec::new();
let mut current = Some(path);
while let Some(folder) = current.filter(|folder| !folder.as_os_str().is_empty() && !folder.exists()) {
missing.push(folder.to_path_buf());
current = folder.parent();
}
missing.reverse();
missing
}
fn occurrences(haystack: &str, needle: &str) -> usize {
let step = needle.chars().next().map_or(1, char::len_utf8);
let mut count = 0;
let mut from = 0;
while let Some(at) = haystack[from..].find(needle) {
count += 1;
from += at + step;
}
count
}
fn count_lines(text: &str) -> usize {
text.lines().count()
}
fn lines_word(count: usize) -> String {
if count == 1 { "1 line".to_owned() } else { format!("{count} lines") }
}