ito-core 0.1.31

Core functionality and business logic for Ito
Documentation
use std::path::Path;

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum MarkerError {
    #[error("Invalid marker state in {file_path}. End marker appears before start marker.")]
    EndBeforeStart { file_path: String },

    #[error(
        "Invalid marker state in {file_path}. Found start: {found_start}, Found end: {found_end}"
    )]
    MissingMarker {
        file_path: String,
        found_start: bool,
        found_end: bool,
    },
}

#[derive(Debug, thiserror::Error)]
pub enum FsEditError {
    #[error(transparent)]
    Marker(#[from] MarkerError),

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

fn is_marker_on_own_line(content: &str, marker_index: usize, marker_len: usize) -> bool {
    let bytes = content.as_bytes();

    let mut i = marker_index;
    while i > 0 {
        let c = bytes[i - 1];
        if c == b'\n' {
            break;
        }
        if c != b' ' && c != b'\t' && c != b'\r' {
            return false;
        }
        i -= 1;
    }

    let mut j = marker_index + marker_len;
    while j < bytes.len() {
        let c = bytes[j];
        if c == b'\n' {
            break;
        }
        if c != b' ' && c != b'\t' && c != b'\r' {
            return false;
        }
        j += 1;
    }

    true
}

fn find_marker_index(content: &str, marker: &str, from_index: usize) -> Option<usize> {
    let mut search_from = from_index;
    while let Some(rel) = content[search_from..].find(marker) {
        let idx = search_from + rel;
        if is_marker_on_own_line(content, idx, marker.len()) {
            return Some(idx);
        }
        search_from = idx + marker.len();
    }
    None
}

pub fn update_content_with_markers(
    file_path: &Path,
    existing: Option<&str>,
    new_block_content: &str,
    start_marker: &str,
    end_marker: &str,
) -> Result<String, MarkerError> {
    let Some(existing) = existing else {
        return Ok(format!(
            "{start}\n{body}\n{end}",
            start = start_marker,
            body = new_block_content,
            end = end_marker
        ));
    };

    let start = find_marker_index(existing, start_marker, 0);
    let end = match start {
        Some(start_idx) => find_marker_index(existing, end_marker, start_idx + start_marker.len()),
        None => find_marker_index(existing, end_marker, 0),
    };

    match (start, end) {
        (Some(start_idx), Some(end_idx)) => {
            if end_idx < start_idx {
                return Err(MarkerError::EndBeforeStart {
                    file_path: file_path.display().to_string(),
                });
            }
            let before = &existing[..start_idx];
            let after = &existing[end_idx + end_marker.len()..];
            Ok(format!(
                "{before}{start}\n{body}\n{end}{after}",
                before = before,
                start = start_marker,
                body = new_block_content,
                end = end_marker,
                after = after
            ))
        }
        (None, None) => Ok(format!(
            "{start}\n{body}\n{end}\n\n{rest}",
            start = start_marker,
            body = new_block_content,
            end = end_marker,
            rest = existing
        )),
        (Some(_), None) => Err(MarkerError::MissingMarker {
            file_path: file_path.display().to_string(),
            found_start: true,
            found_end: false,
        }),
        (None, Some(_)) => Err(MarkerError::MissingMarker {
            file_path: file_path.display().to_string(),
            found_start: false,
            found_end: true,
        }),
    }
}

pub fn update_file_with_markers(
    file_path: &Path,
    new_block_content: &str,
    start_marker: &str,
    end_marker: &str,
) -> Result<String, FsEditError> {
    let existing = std::fs::read_to_string(file_path).ok();
    let updated = update_content_with_markers(
        file_path,
        existing.as_deref(),
        new_block_content,
        start_marker,
        end_marker,
    )?;

    if let Some(parent) = file_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(file_path, &updated)?;
    Ok(updated)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    const START: &str = "<!-- ITO:START -->";
    const END: &str = "<!-- ITO:END -->";

    fn p(name: &str) -> PathBuf {
        PathBuf::from(name)
    }

    #[test]
    fn marker_must_be_on_own_line() {
        let content = format!("prefix {START}\nX\n{END}\n");
        let err =
            update_content_with_markers(&p("f"), Some(&content), "NEW", START, END).unwrap_err();
        assert_eq!(
            err,
            MarkerError::MissingMarker {
                file_path: "f".to_string(),
                found_start: false,
                found_end: true
            }
        );
    }

    #[test]
    fn replaces_existing_block_preserving_unmanaged_content() {
        let existing = format!("line1\n{START}\nold\n{END}\nline2\n");
        let out = update_content_with_markers(&p("f"), Some(&existing), "new", START, END).unwrap();
        assert_eq!(out, format!("line1\n{START}\nnew\n{END}\nline2\n"));
    }

    #[test]
    fn inserts_block_when_missing() {
        let existing = "hello\nworld\n";
        let out = update_content_with_markers(&p("f"), Some(existing), "x", START, END).unwrap();
        assert_eq!(out, format!("{START}\nx\n{END}\n\nhello\nworld\n"));
    }

    #[test]
    fn errors_when_only_one_marker_found() {
        let existing = format!("{START}\nno end\n");
        let err =
            update_content_with_markers(&p("f"), Some(&existing), "x", START, END).unwrap_err();
        assert_eq!(
            err,
            MarkerError::MissingMarker {
                file_path: "f".to_string(),
                found_start: true,
                found_end: false
            }
        );
    }

    #[test]
    fn updates_file_on_disk() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("a.txt");
        let out = update_file_with_markers(&file, "hello", START, END).unwrap();
        assert_eq!(std::fs::read_to_string(&file).unwrap(), out);
    }

    #[test]
    fn idempotent_when_applying_same_content_twice() {
        let existing = format!("{START}\nhello\n{END}\n");
        let once =
            update_content_with_markers(&p("f"), Some(&existing), "hello", START, END).unwrap();
        let twice = update_content_with_markers(&p("f"), Some(&once), "hello", START, END).unwrap();
        assert_eq!(once, twice);
    }
}