idet-core 0.4.1

Editing logic for text editors, without a frontend
Documentation
//! Where the caret stood in the file that was closed last, so that opening it
//! again lands in the same place.

use std::{
    fs, io,
    path::{Path, PathBuf},
};

/// Returns the zero-indexed line and column `path` was left at, or `None` when
/// the last closed file was a different one.
#[must_use]
pub fn recall(path: &Path) -> Option<(usize, usize)> {
    let (stored, position) = entry(&read())?;
    (stored == canonical(path)).then_some(position)
}

/// Stores the zero-indexed `line` and `column` as the position of `path`,
/// replacing whichever file was remembered before.
///
/// # Errors
///
/// Fails when the state directory cannot be determined or written to.
pub fn remember(path: &Path, line: usize, column: usize) -> io::Result<()> {
    let Some(file) = file() else {
        return Err(io::Error::other("no state directory"));
    };
    if let Some(parent) = file.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(file, render(&canonical(path), line, column))
}

/// Where the position is stored.
///
/// That is `$XDG_STATE_HOME/idet/position`, or `~/.local/state/idet/position`
/// when the variable is unset, and `None` when the environment names neither a
/// state directory nor a home directory.
#[must_use]
pub fn file() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_STATE_HOME")
        .map(PathBuf::from)
        .filter(|path| path.is_absolute())
        .or_else(|| {
            std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("state"))
        })?;
    Some(base.join("idet").join("position"))
}

fn read() -> String {
    file()
        .and_then(|path| fs::read_to_string(path).ok())
        .unwrap_or_default()
}

fn canonical(path: &Path) -> PathBuf {
    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

fn entry(text: &str) -> Option<(PathBuf, (usize, usize))> {
    let mut fields = text.trim_end().splitn(3, ' ');
    let line = fields.next()?.parse().ok()?;
    let column = fields.next()?.parse().ok()?;
    let path = fields.next()?;
    (!path.is_empty()).then(|| (PathBuf::from(path), (line, column)))
}

fn render(path: &Path, line: usize, column: usize) -> String {
    format!("{line} {column} {}\n", path.display())
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use super::{entry, render};

    #[test]
    fn what_is_written_is_what_is_read_back() {
        let path = Path::new("/home/p/a file with spaces.txt");
        assert_eq!(
            entry(&render(path, 12, 3)),
            Some((path.to_path_buf(), (12, 3)))
        );
    }

    #[test]
    fn a_broken_line_is_no_position() {
        assert_eq!(entry(""), None);
        assert_eq!(entry("nonsense"), None);
        assert_eq!(entry("7 /home/p/missing-column.txt"), None);
        assert_eq!(entry("1 2 "), None);
        assert_eq!(
            entry("1 2 /home/p/good.txt"),
            Some((PathBuf::from("/home/p/good.txt"), (1, 2)))
        );
    }
}