use super::{
announce,
dto::MAX_PAYLOAD_BYTES,
format::{parse_and_validate_plan, payload_with_name},
list::refresh_list,
naming::{EXPORT_SUFFIX, clean_plan_name, current_unix_time, export_file_name},
open::{OpenedPlan, PLAN_RUNNING_MESSAGE, begin_open, check_staleness, finish_open},
show_error, spawn_task, store,
};
use crate::{
RoughPlanModel,
gui::{
pickers::{PickerFilter, PickerKind, PickerRequest, pick},
rough_plan::{
host::{Host, on_host},
run::guard_unsaved_results,
},
},
};
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, Model};
use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Mutex,
};
fn plan_filter() -> PickerFilter {
PickerFilter {
label: "Indicatrix rough plan".to_string(),
extensions: vec!["toml".to_string()],
}
}
fn listed_name(host: &Host, id: i64) -> Option<String> {
host.window
.global::<RoughPlanModel>()
.get_saved_plans()
.iter()
.find(|row| i64::from(row.id) == id)
.map(|row| row.name.to_string())
}
fn with_toml_extension(path: PathBuf) -> PathBuf {
let is_toml = path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("toml"));
if is_toml {
path
} else {
let mut name = path.into_os_string();
name.push(EXPORT_SUFFIX);
PathBuf::from(name)
}
}
fn write_replacing(target: &Path, text: &str) -> Result<(), String> {
let file_name = target.file_name().map_or_else(
|| "plan".to_string(),
|name| name.to_string_lossy().into_owned(),
);
let temp = target.with_file_name(format!(".{file_name}.tmp"));
let failed = |e: std::io::Error| format!("Could not write {}: {e}", target.display());
std::fs::write(&temp, text).map_err(|e| {
let _ = std::fs::remove_file(&temp);
failed(e)
})?;
std::fs::rename(&temp, target).map_err(|e| {
let _ = std::fs::remove_file(&temp);
failed(e)
})
}
fn export_to_file(db: &Mutex<Database>, id: i64, path: PathBuf) -> Result<PathBuf, String> {
let stored = store::load_saved(db, id)?;
let text = payload_with_name(&stored.payload, &stored.name);
let target = with_toml_extension(path);
write_replacing(&target, &text)?;
Ok(target)
}
pub(super) fn export_saved(host: &Rc<Host>, id: i64) {
let name = listed_name(host, id).unwrap_or_default();
ask_export_path(host, id, export_file_name(&name), None);
}
fn ask_export_path(host: &Rc<Host>, id: i64, default_name: String, starting_dir: Option<PathBuf>) {
let Some(main) = host.main.upgrade() else {
return;
};
let request = PickerRequest {
kind: PickerKind::SaveFile,
title: Some("Export rough plan".to_string()),
filters: vec![plan_filter()],
default_file_name: Some(default_name),
starting_dir,
};
pick(&main, request, move |_main, chosen| {
let Some(path) = chosen else {
return;
};
let target = with_toml_extension(path.clone());
if target != path && target.exists() {
let name = target
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let dir = target.parent().map(Path::to_path_buf);
on_host(|host| ask_export_path(host, id, name, dir));
return;
}
on_host(|host| {
spawn_task(
host,
move |db| export_to_file(db, id, path),
|host, result| match result {
Ok(target) => {
announce(host, &format!("Exported the plan to {}.", target.display()));
}
Err(message) => show_error(host, &message),
},
);
});
});
}
fn prepare_import(db: &Mutex<Database>, path: &Path) -> Result<OpenedPlan, String> {
let file = path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
);
let size = std::fs::metadata(path)
.map_err(|e| format!("Could not read {file}: {e}"))?
.len();
if size > u64::try_from(MAX_PAYLOAD_BYTES).unwrap_or(u64::MAX) {
return Err(format!("{file} is too large to be a rough plan."));
}
let text = std::fs::read_to_string(path).map_err(|e| format!("Could not read {file}: {e}"))?;
let plan = parse_and_validate_plan(&text)
.map_err(|e| format!("{file} is not a usable rough plan: {e}"))?;
let name = match clean_plan_name(&plan.name).as_str() {
"" => "Imported rough plan".to_string(),
cleaned => cleaned.to_string(),
};
let plan_id = store::save_plan(db, &name, &text)?;
let staleness = check_staleness(db, &plan)?;
Ok(OpenedPlan {
plan_id,
name,
created_at: current_unix_time(),
plan,
staleness,
})
}
fn start_import(host: &Rc<Host>, path: PathBuf) {
let Some(seq) = begin_open(host) else {
return;
};
show_error(host, "");
spawn_task(
host,
move |db| prepare_import(db, &path),
move |host, result| {
finish_open(host, seq, result);
refresh_list(host);
},
);
}
fn import_from(host: &Rc<Host>, path: PathBuf) {
guard_unsaved_results(host, move |host| start_import(host, path));
}
pub(super) fn import_saved(host: &Rc<Host>) {
if host.window.global::<RoughPlanModel>().get_running() {
show_error(host, PLAN_RUNNING_MESSAGE);
return;
}
let Some(main) = host.main.upgrade() else {
return;
};
let request = PickerRequest {
kind: PickerKind::OpenFile,
title: Some("Import rough plan".to_string()),
filters: vec![plan_filter()],
default_file_name: None,
starting_dir: None,
};
pick(&main, request, |_main, chosen| {
if let Some(path) = chosen {
on_host(|host| import_from(host, path));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_toml_path_is_kept_and_anything_else_gets_the_export_suffix() {
let kept = with_toml_extension(PathBuf::from("plans").join("a.indicatrix-rough.toml"));
assert_eq!(kept, PathBuf::from("plans").join("a.indicatrix-rough.toml"));
let upper = with_toml_extension(PathBuf::from("b.TOML"));
assert_eq!(upper, PathBuf::from("b.TOML"));
let bare = with_toml_extension(PathBuf::from("plans").join("c"));
assert_eq!(bare, PathBuf::from("plans").join("c.indicatrix-rough.toml"));
let other = with_toml_extension(PathBuf::from("d.txt"));
assert_eq!(other, PathBuf::from("d.txt.indicatrix-rough.toml"));
}
#[test]
fn an_export_replaces_the_old_file_whole_and_leaves_no_temporary_file() {
let dir = std::env::temp_dir().join(format!(
"indicatrix-rough-export-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("the test directory is created");
let target = dir.join("plan.indicatrix-rough.toml");
write_replacing(&target, "first, and longer than the second").expect("first write");
write_replacing(&target, "second").expect("second write");
assert_eq!(
std::fs::read_to_string(&target).expect("readable"),
"second"
);
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("listable")
.filter_map(Result::ok)
.map(|entry| entry.file_name())
.collect();
assert_eq!(
leftovers,
vec![std::ffi::OsString::from("plan.indicatrix-rough.toml")]
);
std::fs::remove_dir_all(&dir).expect("the test directory is removed");
}
#[test]
fn an_export_into_a_missing_directory_fails_with_the_target_named() {
let target = std::env::temp_dir()
.join("indicatrix-rough-no-such-dir")
.join("plan.indicatrix-rough.toml");
let message = write_replacing(&target, "x").expect_err("the directory does not exist");
assert!(message.contains("plan.indicatrix-rough.toml"), "{message}");
}
}