use super::Memory;
use crate::clock;
use crate::memory_cover::{fmt_epoch, CoverOpts};
use crate::out::Out;
use anyhow::{anyhow, bail, Context, Result};
use std::path::{Path, PathBuf};
const COVER_DEFAULT_CHARS: usize = 400_000;
const COVER_DEFAULT_CHUNK_CHARS: usize = 20_000;
const COVER_TEXT_FILE: &str = "cover.txt";
const COVER_CURSOR_FILE: &str = "cursor.json";
#[derive(serde::Serialize, serde::Deserialize)]
pub(super) struct CoverCursor {
pub(super) offset: usize,
pub(super) chunk_chars: usize,
pub(super) total_chars: usize,
pub(super) generated_at: String,
}
pub(super) fn cover_session_dir(key: &str) -> Result<PathBuf> {
if key.is_empty() || key == "." || key == ".." || key.contains('/') || key.contains('\\') {
bail!("invalid session key `{key}` (it becomes a directory name)");
}
let base = match std::env::var_os("XDG_CACHE_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => {
let home = std::env::var_os("HOME")
.ok_or_else(|| anyhow!("neither XDG_CACHE_HOME nor HOME is set"))?;
PathBuf::from(home).join(".cache")
}
};
Ok(base.join("faculties").join("cover").join(key))
}
pub(super) fn cover_chunk_count(total_chars: usize, chunk_chars: usize) -> usize {
total_chars.div_ceil(chunk_chars)
}
pub(super) fn cover_byte_of_char(text: &str, chars: usize) -> usize {
text.char_indices()
.nth(chars)
.map(|(byte, _)| byte)
.unwrap_or(text.len())
}
pub(super) fn cover_save_cursor(dir: &Path, cursor: &CoverCursor) -> Result<()> {
let path = dir.join(COVER_CURSOR_FILE);
let json = serde_json::to_string_pretty(cursor).context("encode cover cursor")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))
}
pub(super) fn cover_write_state(
dir: &Path,
cover: &str,
chunk_chars: usize,
generated_at: String,
) -> Result<(usize, usize)> {
std::fs::create_dir_all(dir)
.with_context(|| format!("create cover state dir {}", dir.display()))?;
let text_path = dir.join(COVER_TEXT_FILE);
std::fs::write(&text_path, cover).with_context(|| format!("write {}", text_path.display()))?;
let total_chars = cover.chars().count();
let cursor = CoverCursor {
offset: 0,
chunk_chars,
total_chars,
generated_at,
};
cover_save_cursor(dir, &cursor)?;
Ok((cover_chunk_count(total_chars, chunk_chars), total_chars))
}
pub(super) fn cover_read_state(dir: &Path) -> Result<Option<(String, CoverCursor)>> {
let text_path = dir.join(COVER_TEXT_FILE);
let cursor_path = dir.join(COVER_CURSOR_FILE);
if !text_path.exists() || !cursor_path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&text_path)
.with_context(|| format!("read {}", text_path.display()))?;
let raw = std::fs::read_to_string(&cursor_path)
.with_context(|| format!("read {}", cursor_path.display()))?;
let cursor: CoverCursor =
serde_json::from_str(&raw).with_context(|| format!("parse {}", cursor_path.display()))?;
if cursor.chunk_chars == 0 {
bail!(
"corrupt cover cursor {} (chunk_chars is 0) — rerun `memory cover start`",
cursor_path.display()
);
}
Ok(Some((text, cursor)))
}
pub(super) enum CoverStep {
AlreadyComplete {
chunks: usize,
},
Chunk {
index: usize,
chunks: usize,
start_char: usize,
end_char: usize,
text: String,
complete: bool,
},
}
pub(super) fn cover_advance(dir: &Path) -> Result<CoverStep> {
let Some((text, mut cursor)) = cover_read_state(dir)? else {
bail!(
"no cover in {} — run `memory cover start` first",
dir.display()
);
};
let chunks = cover_chunk_count(cursor.total_chars, cursor.chunk_chars);
if cursor.offset >= cursor.total_chars {
return Ok(CoverStep::AlreadyComplete { chunks });
}
let start_char = cursor.offset;
let end_char = (start_char + cursor.chunk_chars).min(cursor.total_chars);
let chunk = text[cover_byte_of_char(&text, start_char)..cover_byte_of_char(&text, end_char)]
.to_string();
cursor.offset = end_char;
cover_save_cursor(dir, &cursor)?;
Ok(CoverStep::Chunk {
index: start_char / cursor.chunk_chars + 1,
chunks,
start_char,
end_char,
text: chunk,
complete: end_char >= cursor.total_chars,
})
}
pub(super) fn cover_status_line(dir: &Path) -> Result<(String, bool)> {
let Some((_, cursor)) = cover_read_state(dir)? else {
return Ok(("complete=false loaded=0/0 chars=0/0".to_string(), false));
};
let chunks = cover_chunk_count(cursor.total_chars, cursor.chunk_chars);
let complete = cursor.offset >= cursor.total_chars;
let loaded = if complete {
chunks
} else {
cursor.offset / cursor.chunk_chars
};
Ok((
format!(
"complete={complete} loaded={loaded}/{chunks} chars={}/{}",
cursor.offset, cursor.total_chars
),
complete,
))
}
pub(super) fn cover_reset_cursor(dir: &Path) -> Result<usize> {
let Some((_, mut cursor)) = cover_read_state(dir)? else {
bail!(
"no cover in {} — run `memory cover start` first",
dir.display()
);
};
cursor.offset = 0;
cover_save_cursor(dir, &cursor)?;
Ok(cover_chunk_count(cursor.total_chars, cursor.chunk_chars))
}
pub(super) fn execute(memory: &Memory, args: &[String], out: &mut Out<'_>) -> Result<i32> {
let usage = "usage: memory cover start [--chars N] [--chunk-chars M] [--session KEY]\n\
\x20 memory cover continue [--session KEY]\n\
\x20 memory cover status [--session KEY]\n\
\x20 memory cover reset [--session KEY]";
let Some(verb) = args.first().map(String::as_str) else {
bail!("{usage}");
};
if matches!(verb, "--help" | "-h") {
out.line(format!("{usage}"))?;
return Ok(0);
}
let mut budget_chars: usize = COVER_DEFAULT_CHARS;
let mut chunk_chars: usize = COVER_DEFAULT_CHUNK_CHARS;
let mut session = "default".to_string();
{
let mut i = 1;
while i < args.len() {
let flag = args[i].as_str();
match flag {
"--chars" | "--chunk-chars" | "--session" => {
let raw = args
.get(i + 1)
.ok_or_else(|| anyhow!("{flag} needs a value\n{usage}"))?;
match flag {
"--session" => session = raw.clone(),
_ => {
let n: usize = raw.parse().map_err(|_| {
anyhow!("{flag} expects a positive integer, got `{raw}`")
})?;
if n == 0 {
bail!("{flag} must be positive");
}
if flag == "--chars" {
budget_chars = n;
} else {
chunk_chars = n;
}
}
}
i += 2;
}
other => bail!("unknown argument `{other}`\n{usage}"),
}
}
}
let dir = cover_session_dir(&session)?;
match verb {
"start" => {
let cover = super::cli::report_text(memory.context(&CoverOpts::plain(budget_chars))?);
let now = clock::now().context("generate cover state timestamp")?;
let (chunks, total) = cover_write_state(&dir, &cover, chunk_chars, fmt_epoch(now))?;
out.line(format!(
"cover: generated {chunks} chunks (~{chunk_chars} chars each, {total} chars total); run 'memory cover continue'"
))?;
Ok(0)
}
"continue" => match cover_advance(&dir)? {
CoverStep::AlreadyComplete { chunks } => {
out.line(format!("COVER COMPLETE {chunks}/{chunks} (nothing to do)"))?;
Ok(0)
}
CoverStep::Chunk {
index,
chunks,
start_char,
end_char,
text,
complete,
} => {
out.line(format!(
"COVER CHUNK {index}/{chunks} (chars {start_char}..{end_char})"
))?;
out.text(format!("{text}"))?;
if !text.ends_with('\n') {
out.line("")?;
}
if complete {
out.line(format!("COVER COMPLETE {chunks}/{chunks}"))?;
}
Ok(0)
}
},
"status" => {
let (line, complete) = cover_status_line(&dir)?;
out.line(format!("{line}"))?;
if !complete {
return Ok(1);
}
Ok(0)
}
"reset" => {
let chunks = cover_reset_cursor(&dir)?;
out.line(format!("cover: cursor reset ({chunks} chunks pending)"))?;
Ok(0)
}
other => bail!("unknown cover subcommand `{other}`\n{usage}"),
}
}