clin-rs 0.8.17

Encrypted terminal note-taking app inspired by Obsidian
use anyhow::{Context, Result};
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::path::PathBuf;

#[derive(Clone, Copy)]
pub enum ConflictAction {
    Skip,
    SkipAll,
    Overwrite,
    OverwriteAll,
}

fn prompt_conflict_action(file_name: &str) -> Result<ConflictAction> {
    println!("  Conflict: '{file_name}' already exists at destination.");
    print!("  Action? [s]kip, skip [a]ll, [o]verwrite, overwrite a[l]l: ");
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;

    match input.trim().to_lowercase().as_str() {
        "s" | "skip" | "" => Ok(ConflictAction::Skip),
        "a" | "skip all" => Ok(ConflictAction::SkipAll),
        "o" | "overwrite" => Ok(ConflictAction::Overwrite),
        "l" | "overwrite all" => Ok(ConflictAction::OverwriteAll),
        _ => {
            println!("  Unknown option, skipping...");
            Ok(ConflictAction::Skip)
        }
    }
}

pub fn migrate_file_with_conflict(
    src: &Path,
    dst: &Path,
    display_name: &str,
    current_action: Option<ConflictAction>,
) -> Result<(usize, usize, Option<ConflictAction>)> {
    if !src.exists() {
        return Ok((0, 0, current_action));
    }

    if dst.exists() {
        let action = match current_action {
            Some(ConflictAction::SkipAll) => ConflictAction::SkipAll,
            Some(ConflictAction::OverwriteAll) => ConflictAction::OverwriteAll,
            _ => prompt_conflict_action(display_name)?,
        };

        match action {
            ConflictAction::Skip | ConflictAction::SkipAll => {
                println!("  Skipped: {display_name}");
                let new_action = if matches!(action, ConflictAction::SkipAll) {
                    Some(ConflictAction::SkipAll)
                } else {
                    current_action
                };
                Ok((0, 1, new_action))
            }
            ConflictAction::Overwrite | ConflictAction::OverwriteAll => {
                fs::copy(src, dst).with_context(|| format!("failed to copy {}", src.display()))?;
                println!("  Overwritten: {display_name}");
                let new_action = if matches!(action, ConflictAction::OverwriteAll) {
                    Some(ConflictAction::OverwriteAll)
                } else {
                    current_action
                };
                Ok((1, 0, new_action))
            }
        }
    } else {
        fs::copy(src, dst).with_context(|| format!("failed to copy {}", src.display()))?;
        println!("  Migrated: {display_name}");
        Ok((1, 0, current_action))
    }
}

pub fn migrate_directory_with_conflict(
    src: &Path,
    dst: &Path,
    mut current_action: Option<ConflictAction>,
) -> Result<(usize, usize, Option<ConflictAction>)> {
    let mut migrated = 0;
    let mut skipped = 0;

    for entry in fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))? {
        let entry = entry?;
        let src_path = entry.path();
        let file_name = entry.file_name();
        let dst_path = dst.join(&file_name);
        let display_name = file_name.to_string_lossy().to_string();

        if src_path.is_dir() {
            fs::create_dir_all(&dst_path)
                .with_context(|| format!("failed to create {}", dst_path.display()))?;
            let (m, s, action) =
                migrate_directory_with_conflict(&src_path, &dst_path, current_action)?;
            migrated += m;
            skipped += s;
            current_action = action;
        } else if src_path.is_file() {
            let (m, s, action) =
                migrate_file_with_conflict(&src_path, &dst_path, &display_name, current_action)?;
            migrated += m;
            skipped += s;
            current_action = action;
        }
    }

    Ok((migrated, skipped, current_action))
}

/// Copy note files from `src` to `dst`, preserving relative paths.
/// Skips hidden directories and `notes/`, `templates/`, `.clin/` subdirectories.
/// Only copies files with note extensions (.md, .txt, .clin, .draw, .canvas).
pub fn migrate_note_files_with_conflict(
    src: &Path,
    dst: &Path,
    current_action: Option<ConflictAction>,
) -> Result<(usize, usize, Option<ConflictAction>)> {
    let note_exts = ["md", "txt", "clin", "draw", "canvas"];
    let mut migrated = 0;
    let mut skipped = 0;
    let mut current_action = current_action;
    let mut dirs_to_visit = vec![PathBuf::new()];

    while let Some(rel) = dirs_to_visit.pop() {
        let abs = src.join(&rel);
        let Ok(entries) = fs::read_dir(&abs) else {
            continue;
        };
        for entry in entries.flatten() {
            let file_name = entry.file_name();
            let abs_path = entry.path();
            let rel_path = rel.join(&file_name);

            if abs_path.is_dir() {
                // Skip hidden dirs and clin internal dirs
                if let Some(name) = file_name.to_str()
                    && (name.starts_with('.') || name == "notes" || name == "templates")
                {
                    continue;
                }
                dirs_to_visit.push(rel_path);
            } else if abs_path.is_file()
                && let Some(ext) = abs_path.extension().and_then(|e| e.to_str())
                && note_exts.contains(&ext)
            {
                let dst_path = dst.join(&rel_path);
                if let Some(parent) = dst_path.parent() {
                    fs::create_dir_all(parent)
                        .with_context(|| format!("failed to create {}", parent.display()))?;
                }
                let display_name = rel_path.to_string_lossy().to_string();
                let (m, s, action) = migrate_file_with_conflict(
                    &abs_path,
                    &dst_path,
                    &display_name,
                    current_action,
                )?;
                migrated += m;
                skipped += s;
                current_action = action;
            }
        }
    }

    Ok((migrated, skipped, current_action))
}