mod cli;
pub mod colours;
pub mod config;
mod db;
mod error;
mod note;
mod preview;
mod search;
mod task;
use crate::cli::{ExportFormat, SortBy};
use crate::note::{JsonExport, Note};
use crate::task::{Task, TaskStatus};
use atty::Stream;
use chrono::Utc;
use clap::CommandFactory;
pub use cli::{Cli, Commands};
use colored::Colorize;
use config::Config;
use crossbeam_channel::unbounded;
use dialoguer::Confirm;
use error::AppError;
use regex::Regex;
use crate::preview::PreviewApp;
use rumdl_lib::lint;
#[cfg(unix)]
use skim::options::SkimOptionsBuilder;
#[cfg(unix)]
use skim::{Skim, SkimItem};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{env, fs, io};
use tempfile::Builder as TempBuilder;
pub fn initialise_search_index(config: &Config) -> Result<tantivy::Index, AppError> {
let search_index_path = match env::var("MEDI_DB_PATH") {
Ok(path_str) => PathBuf::from(path_str).join("search_index"),
Err(_) => config
.db_path
.as_ref()
.map(|db_path| db_path.join("search_index"))
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("medi")
.join("search_index")
}),
};
let index = search::open_index(&search_index_path)?;
Ok(index)
}
fn format_tags(tags: &[String]) -> String {
if tags.is_empty() {
"".to_string()
} else {
format!(
" [{}]",
tags.iter()
.map(|t| format!("#{}", t).cyan().to_string())
.collect::<Vec<String>>()
.join(" ")
)
}
}
fn calculate_reading_time(word_count: usize) -> u64 {
let wpm = 225.0;
(word_count as f64 / wpm).ceil() as u64
}
fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
fn run_linter_on_notes(notes_to_lint: Vec<Note>) -> Result<usize, AppError> {
let mut total_issues = 0;
let config = rumdl_lib::config::Config::default();
let all_rules = rumdl_lib::rules::all_rules(&config);
for note in notes_to_lint {
let issues = lint(¬e.content, &all_rules, false, config.markdown_flavor())?;
if !issues.is_empty() {
println!("\n📝 Found issues in '{}':", note.key.bold());
for issue in issues {
println!(
" - {} (Line: {}, Rule: {})",
issue.message.yellow(),
issue.line,
issue.rule_name.as_deref().unwrap_or("<unknown>")
);
total_issues += 1;
}
}
}
Ok(total_issues)
}
pub fn run(cli: Cli, config: Config) -> Result<(), AppError> {
let db = db::open(config.clone())?; let search_index =
initialise_search_index(&config).map_err(|e| AppError::Search(e.to_string()))?;
match cli.command {
Commands::New {
key,
message,
title,
tag,
template,
} => {
if db::key_exists(&db, &key)? {
return Err(AppError::KeyExists(key));
}
let content = if let Some(message_content) = message {
message_content
} else if !atty::is(Stream::Stdin) {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
buffer
} else {
let initial_content = if let Some(template_name) = template {
let config_dir = dirs::config_dir().ok_or_else(|| {
AppError::ConfigError("Config directory not found".into())
})?;
let template_path = config_dir
.join("medi/templates")
.join(format!("{}.md", template_name));
fs::read_to_string(template_path).unwrap_or_default()
} else {
String::new()
};
let tempfile = TempBuilder::new()
.prefix("medi-note-")
.suffix(".md")
.tempfile()?;
let temppath = tempfile.path().to_path_buf();
fs::write(&temppath, &initial_content)?;
edit::edit_file(&temppath)?;
fs::read_to_string(&temppath)?
};
if content.trim().is_empty() {
colours::warn("Note creation cancelled (empty content).");
} else {
let new_note = Note {
key: key.clone(),
title: title.unwrap_or_else(|| key.clone()),
tags: tag,
content,
created_at: Utc::now(),
modified_at: Utc::now(),
};
db::save_note_with_index(&db, &new_note, &search_index)?;
colours::success(&format!("Successfully created note: '{}'", key));
}
}
Commands::Edit {
key,
add_tag,
rm_tag,
} => {
let mut existing_note = db::get_note(&db, &key)?;
let mut modified = false;
if !add_tag.is_empty() {
for tag in add_tag {
if !existing_note.tags.contains(&tag) {
existing_note.tags.push(tag);
modified = true;
}
}
}
if !rm_tag.is_empty() {
let original_len = existing_note.tags.len();
existing_note.tags.retain(|tag| !rm_tag.contains(tag));
if existing_note.tags.len() != original_len {
modified = true;
}
}
if modified {
existing_note.modified_at = Utc::now();
db::save_note_with_index(&db, &existing_note, &search_index)?;
colours::success(&format!("Successfully updated tags for '{}'", key));
return Ok(());
}
let tempfile = TempBuilder::new()
.prefix("medi-note-")
.suffix(".md")
.tempfile()?;
let temppath = tempfile.path().to_path_buf();
fs::write(&temppath, &existing_note.content)?;
edit::edit_file(&temppath)?;
let updated_content = fs::read_to_string(&temppath)?;
if updated_content.trim() != existing_note.content.trim() {
existing_note.content = updated_content;
existing_note.modified_at = Utc::now();
db::save_note_with_index(&db, &existing_note, &search_index)?;
colours::success(&format!("Successfully updated note: '{}'", key));
} else {
colours::info("Note content unchanged.");
}
}
Commands::Get { keys, tag, json } => {
let notes_to_show = if !tag.is_empty() {
let all_notes = db::get_all_notes(&db)?;
all_notes
.into_iter()
.filter(|note| note.tags.iter().any(|t| tag.contains(t)))
.collect::<Vec<_>>()
} else {
let mut notes = Vec::new();
for key in keys {
notes.push(db::get_note(&db, &key)?);
}
notes
};
if notes_to_show.is_empty() {
colours::warn("No matching notes found.");
return Ok(());
}
for (i, note) in notes_to_show.iter().enumerate() {
if i > 0 {
println!("---");
} if json {
println!("{}", serde_json::to_string_pretty(note)?);
} else {
println!("{}", note.content);
}
}
}
Commands::List { sort_by } => {
let mut notes = db::get_all_notes(&db)?;
if notes.is_empty() {
colours::warn("No notes found.");
}
match sort_by {
SortBy::Key => notes.sort_by(|a, b| a.key.cmp(&b.key)),
SortBy::Created => notes.sort_by(|a, b| b.created_at.cmp(&a.created_at)), SortBy::Modified => notes.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)), }
println!("{}:", "Notes".bold().underline());
for note in notes {
let tags_str = format_tags(¬e.tags);
println!("- {}{}", note.key.green().bold(), tags_str);
}
}
Commands::Backlinks { key } => {
let all_notes = db::get_all_notes(&db)?;
let link_pattern = format!(r"\[\[{}\]\]", regex::escape(&key));
let re = Regex::new(&link_pattern)?;
let mut linking_notes = Vec::new();
for note in all_notes {
if note.key == key {
continue;
}
if re.is_match(¬e.content) {
linking_notes.push(note.key);
}
}
if linking_notes.is_empty() {
colours::warn(&format!("No backlinks found for '{}'.", key));
} else {
colours::info(&format!(
"Found {} backlinks for '{}':",
linking_notes.len(),
key.bold()
));
for linking_key in linking_notes {
println!("- {}", linking_key);
}
}
}
Commands::Delete { key, force } => {
let confirmed = if force {
true
} else {
Confirm::new()
.with_prompt(format!("Are you sure you want to delete '{}'?", key))
.default(false)
.interact()?
};
if confirmed {
let deleted_tasks_count = db::delete_tasks_for_note(&db, &key)?;
if deleted_tasks_count > 0 {
colours::info(&format!(
"Deleted {} associated task(s).",
deleted_tasks_count
));
}
db::delete_note_with_index(&db, &key, &search_index)?;
colours::success(&format!("Successfully deleted note: '{}'", key));
} else {
colours::warn("Deletion cancelled.");
}
}
Commands::Search { query } => {
let found_keys = search::search_notes(&search_index, &query)?;
if found_keys.is_empty() {
colours::warn("No matching notes found.");
return Ok(());
}
println!("{}:", "Search Results".bold().underline());
for key in found_keys {
match db::get_note(&db, &key) {
Ok(note) => {
let tags_str = format_tags(¬e.tags);
println!("- {}{}", note.key.green().bold(), tags_str);
}
Err(_) => {
colours::error(&format!(
"Found key '{}' in index, but failed to retrieve from database.",
key
));
}
}
}
}
Commands::Reindex => {
colours::info("Starting reindex of all notes...");
let all_notes = db::get_all_notes(&db)?;
let note_count = all_notes.len();
let mut index_writer: tantivy::IndexWriter<tantivy::TantivyDocument> =
search_index.writer(100_000_000)?; index_writer.delete_all_documents()?;
for note in all_notes {
search::add_note_to_index(¬e, &mut index_writer)?;
}
index_writer.commit()?;
colours::success(&format!("Successfully reindexed {} notes.", note_count));
}
#[cfg(unix)]
Commands::Find => {
let notes = db::get_all_notes(&db)?;
if notes.is_empty() {
colours::warn("No notes to find.");
return Ok(());
}
let (tx, rx) = unbounded();
for note in notes {
let item: Arc<dyn SkimItem> = Arc::new(note.key);
let _ = tx.send(item);
}
drop(tx);
let options = SkimOptionsBuilder::default()
.height("30%".to_string())
.prompt("Select a note to edit: ".to_string())
.reverse(true)
.border(Some("─".to_string()))
.multi(false)
.build()
.unwrap();
let selected_items = Skim::run_with(&options, Some(rx))
.map(|out| out.selected_items)
.unwrap_or_default();
if let Some(item) = selected_items.first() {
let selected_key = item.output().to_string();
let mut existing_note = db::get_note(&db, &selected_key)?;
let tempfile = TempBuilder::new()
.prefix("medi-note-")
.suffix(".md")
.tempfile()?;
let temppath = tempfile.path().to_path_buf();
fs::write(&temppath, &existing_note.content)?;
edit::edit_file(&temppath)?;
let updated_content = fs::read_to_string(&temppath)?;
if updated_content.trim() != existing_note.content.trim() {
existing_note.content = updated_content;
existing_note.modified_at = Utc::now();
db::save_note_with_index(&db, &existing_note, &search_index)?;
colours::success(&format!("Successfully updated note: '{}'", selected_key));
} else {
colours::info("Note content unchanged.");
}
} else {
colours::info("No note selected.");
}
}
#[cfg(not(unix))]
Commands::Find => {
return Err(AppError::Unsupported(
"The 'find' command is not supported on this operating system.".to_string(),
));
}
Commands::Import(args) => {
let handle_import = |key: &str, content: &str| -> Result<(), AppError> {
if let Ok(existing_note) = db::get_note(&db, key) {
if !args.overwrite {
colours::warn(&format!("Skipped '{}' (already exists)", key));
return Ok(());
}
let mut updated_note = existing_note;
updated_note.content = content.to_string();
updated_note.modified_at = Utc::now();
db::save_note_with_index(&db, &updated_note, &search_index)?;
colours::success(&format!("Updated '{}'", key));
} else {
let new_note = Note {
key: key.to_string(),
title: key.to_string(), tags: vec![], content: content.to_string(),
created_at: Utc::now(),
modified_at: Utc::now(),
};
db::save_note(&db, &new_note)?;
colours::success(&format!("Imported '{}'", key));
}
Ok(())
};
if let (Some(file_path), Some(key)) = (args.file, args.key) {
let content = fs::read_to_string(&file_path)?;
handle_import(&key, &content)?;
} else if let Some(dir_path_str) = args.dir {
let dir_path = Path::new(&dir_path_str);
if !dir_path.is_dir() {
return Err(AppError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Directory not found: {}", dir_path_str),
)));
}
for entry in fs::read_dir(dir_path)? {
let entry = entry?;
let file_path = entry.path();
if file_path.is_file() && file_path.extension() == Some("md".as_ref()) {
if let Some(key) = file_path.file_stem().and_then(|s| s.to_str()) {
let content = fs::read_to_string(&file_path)?;
if let Err(e) = handle_import(key, &content) {
colours::error(&format!("Failed to import '{}': {}", key, e));
}
}
}
}
}
}
Commands::Export(args) => {
let all_notes = db::get_all_notes(&db)?;
let notes_to_export = if !args.tag.is_empty() {
all_notes
.into_iter()
.filter(|note| args.tag.iter().all(|t| note.tags.contains(t)))
.collect()
} else {
all_notes };
let note_count = notes_to_export.len();
if note_count == 0 {
colours::warn("No matching notes to export.");
return Ok(());
}
match args.format {
ExportFormat::Markdown => {
let export_path = Path::new(&args.path);
fs::create_dir_all(export_path)?;
for note in notes_to_export {
let file_path = export_path.join(format!("{}.md", note.key));
fs::write(file_path, ¬e.content)?;
}
colours::success(&format!(
"Successfully exported {} notes as Markdown to '{}'",
note_count, args.path
));
}
ExportFormat::Json => {
let mut path = PathBuf::from(&args.path);
if path.extension().and_then(|s| s.to_str()) != Some("json") {
path.set_extension("json");
}
let export_data = JsonExport {
export_date: Utc::now(),
note_count,
notes: notes_to_export,
};
let json_string = serde_json::to_string_pretty(&export_data)?;
fs::write(&path, json_string)?;
colours::success(&format!(
"Successfully exported {} notes as JSON to '{}'",
note_count,
path.display()
));
}
}
}
Commands::Task { command } => match command {
cli::TaskCommands::Add {
note_key,
description,
} => {
db::get_note(&db, ¬e_key)?;
let new_task = Task {
id: db::get_next_task_id(&db)?,
note_key,
description,
status: TaskStatus::Open,
created_at: Utc::now(),
};
db::save_task(&db, &new_task)?;
colours::success(&format!("Added new task with ID: {}", new_task.id));
}
cli::TaskCommands::List => {
let mut tasks = db::get_all_tasks(&db)?;
let open_tasks: Vec<_> = tasks.clone().clone().into_iter().collect();
if open_tasks.is_empty() {
colours::info("No open tasks.");
} else {
tasks.sort_by_key(|t| match t.status {
TaskStatus::Prio => 0,
TaskStatus::Open => 1,
TaskStatus::Done => 2,
});
colours::info("Open tasks:");
for task in open_tasks {
let status_str = match task.status {
TaskStatus::Open => "[Open]".cyan(),
TaskStatus::Prio => "[Prio] ⭐".yellow().bold(),
TaskStatus::Done => "[Done]".green(),
};
println!(
"[{}] {}: {} (for note {})",
task.id,
status_str,
task.description,
task.note_key.cyan().bold()
);
}
}
}
cli::TaskCommands::Done { task_id } => {
let tasks = db::get_all_tasks(&db)?;
if let Some(mut task) = tasks.into_iter().find(|t| t.id == task_id) {
task.status = TaskStatus::Done;
db::save_task(&db, &task)?;
colours::success(&format!("Completed task: {}", task_id));
} else {
Err(AppError::TaskNotFound(task_id))?;
}
}
cli::TaskCommands::Prio { task_id } => {
let tasks = db::get_all_tasks(&db)?;
if let Some(mut task) = tasks.into_iter().find(|t| t.id == task_id) {
task.status = TaskStatus::Prio;
db::save_task(&db, &task)?;
colours::success(&format!("Prioritised task: {}", task_id));
} else {
Err(AppError::TaskNotFound(task_id))?;
}
}
cli::TaskCommands::Delete { task_id } => {
let tasks = db::get_all_tasks(&db)?;
if tasks.iter().any(|t| t.id == task_id) {
db::delete_task(&db, task_id)?;
colours::success(&format!("Deleted task: {}", task_id));
} else {
Err(AppError::TaskNotFound(task_id))?;
}
}
cli::TaskCommands::Reset { force } => {
let confirmed = if force {
true
} else {
Confirm::new()
.with_prompt("Are you sure you want to reset all tasks?")
.default(false)
.interact()?
};
if confirmed {
db::delete_all_tasks(&db)?;
colours::success("All tasks have been reset.");
} else {
colours::warn("Task reset cancelled.");
}
}
},
Commands::Status { key } => {
if let Some(note_key) = key {
let note = db::get_note(&db, ¬e_key)?;
let word_count = count_words(¬e.content).into();
let reading_time = calculate_reading_time(word_count);
let tags_str = if note.tags.is_empty() {
"None".to_string()
} else {
note.tags.join(", ")
};
println!("{}", note.title.bold().underline());
println!(" Key: {}", note.key.cyan());
println!(" Tags: {}", tags_str.cyan());
println!(" Words: {}", word_count.to_string().cyan());
println!(
" Reading Time: ~{} minute(s)",
reading_time.to_string().cyan()
);
println!(" Created: {}", note.created_at.to_rfc2822());
println!(" Modified: {}", note.modified_at.to_rfc2822());
} else {
let notes = db::get_all_notes(&db)?;
let tasks = db::get_all_tasks(&db)?;
let open_tasks: Vec<_> = tasks
.iter()
.filter(|t| !matches!(t.status, TaskStatus::Done))
.collect();
let prio_tasks_count = open_tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Prio))
.count();
println!("{}", "medi status".bold().underline());
println!(" Notes: {}", notes.len().to_string().cyan());
println!(
" Tasks: {} open ({} priority)",
open_tasks.len().to_string().cyan(),
prio_tasks_count.to_string().yellow()
);
}
}
Commands::Lint { key } => {
colours::info("Running linter...");
let notes_to_lint = if let Some(note_key) = key {
vec![db::get_note(&db, ¬e_key)?]
} else {
db::get_all_notes(&db)?
};
let total_issues = run_linter_on_notes(notes_to_lint)?;
if total_issues == 0 {
colours::success("\n✅ No issues found.");
} else {
colours::warn(&format!("\nFound a total of {} issues.", total_issues));
}
}
Commands::Preview { key } => {
let note = db::get_note(&db, &key)?;
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([800.0, 600.0])
.with_title(format!("Preview: {}", note.title)),
..Default::default()
};
eframe::run_native(
"medi Preview",
native_options,
Box::new(|_cc| Ok(Box::new(PreviewApp::new(note.content)))),
)
.map_err(|e| AppError::GuiError(e.to_string()))?;
}
Commands::Completion { shell } => {
let mut cmd = cli::Cli::command();
let bin_name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, bin_name, &mut io::stdout());
}
Commands::Update => {
println!("{}", "--- Checking for updates ---".blue());
let status = self_update::backends::github::Update::configure()
.repo_owner("cladam")
.repo_name("medi")
.bin_name("medi")
.show_download_progress(true)
.current_version(self_update::cargo_crate_version!())
.build()?
.update()?;
println!("Update status: `{}`!", status.version());
if status.updated() {
println!("{}", "Successfully updated medi!".green());
} else {
println!("{}", "medi is already up to date.".green());
}
}
}
Ok(())
}