mod app;
mod entities;
mod features;
mod screens;
mod shared;
mod widgets;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, anyhow, bail};
use tokio::sync::mpsc::unbounded_channel;
use crate::app::events::{AppCommand, AppEvent};
use crate::app::orchestrator::{self, OrchestratorDeps};
use crate::app::supervisor::{DemoSupervisor, LlamaSupervisor, ServerSupervisor};
use crate::features::backup::{self, RestoreOutcome};
use crate::features::cli::{self, CliCommand};
use crate::shared::config::{AppConfig, ServerMode};
use crate::shared::i18n::{self, Lang, Locale};
use crate::shared::storage::{JsonStore, Storage};
use crate::shared::{instance, logging, paths::Paths};
fn main() -> ExitCode {
let (paths, lang) = match Paths::resolve() {
Ok(paths) => {
let settings_lang = try_settings_language(&paths.settings_file());
let lang = cli_lang(settings_lang, paths.default_language());
(paths, lang)
}
Err(err) => {
print_error(Lang::En, &err);
shared::console::hold_if_sole_owner(i18n::locale(Lang::En));
return ExitCode::FAILURE;
}
};
let locale_warnings = i18n::init(&paths.locales_dir());
let loc = i18n::locale(lang);
let args: Vec<String> = std::env::args().skip(1).collect();
let command = match cli::parse(&args, loc) {
Ok(cmd) => cmd,
Err(msg) => {
eprintln!("{msg}");
shared::console::hold_if_sole_owner(loc);
return ExitCode::from(2);
}
};
match command {
CliCommand::Help { topic } => {
println!("{}", cli::render_help(topic, loc));
return ExitCode::SUCCESS;
}
CliCommand::Version => {
println!(
"{}",
loc.tf(
"cli.version.line",
&[("version", env!("CARGO_PKG_VERSION"))]
)
);
return ExitCode::SUCCESS;
}
_ => {}
}
match real_main(command, &paths, loc, &locale_warnings) {
Ok(code) => code,
Err(err) => {
print_error(lang, &err);
shared::console::hold_if_sole_owner(loc);
ExitCode::FAILURE
}
}
}
fn real_main(
command: CliCommand,
paths: &Paths,
loc: &Locale,
locale_warnings: &[String],
) -> anyhow::Result<ExitCode> {
if refuses_tui_launch(&command, std::io::stdout().is_terminal()) {
eprintln!("{}", loc.t("cli.tui.no_terminal"));
return Ok(ExitCode::from(2));
}
if matches!(command, CliCommand::Demo) {
return run_demo(loc, locale_warnings);
}
if let CliCommand::Stats {
archive,
compare,
password,
json,
} = command
{
return run_stats(
paths,
archive.as_deref(),
compare.as_deref(),
password,
json,
loc,
);
}
paths.ensure_dirs(loc).with_context(|| {
loc.tf(
"cli.ctx.ensure_dirs",
&[("path", &paths.root().display().to_string())],
)
})?;
let _logging =
logging::init(paths, loc).with_context(|| loc.t("cli.ctx.init_logging").to_string())?;
for w in locale_warnings {
tracing::warn!("{w}");
}
match command {
CliCommand::Import { file } => {
run_import(paths, &file, loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::Backup {
output,
compression,
password,
} => {
run_backup(paths, output, compression, password, loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::Restore { archive, password } => {
run_restore(paths, &archive, password, loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::SandboxSetup {
force,
enable_python,
} => {
run_sandbox_setup(paths, force, enable_python, loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::LlamaBackends { build } => {
run_llama_backends(paths, build.as_deref(), loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::LlamaSetup {
backend,
build,
cudart,
force,
set_binary,
} => run_llama_setup(paths, backend, build, cudart, force, set_binary, loc),
CliCommand::LlamaInstalled => {
run_llama_installed(paths, loc);
Ok(ExitCode::SUCCESS)
}
CliCommand::Setup(args) => run_setup(paths, &args, loc),
CliCommand::LlamaRemove { id, force } => {
run_llama_remove(paths, &id, force, loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::LocalesExport { code, output } => {
run_locales_export(&code, &output, loc)?;
Ok(ExitCode::SUCCESS)
}
CliCommand::Run => run_tui(paths, loc),
CliCommand::Demo | CliCommand::Stats { .. } => {
unreachable!("handled above, before the real root is touched")
}
CliCommand::Help { .. } | CliCommand::Version => unreachable!("handled in main"),
}
}
fn refuses_tui_launch(command: &CliCommand, stdout_is_terminal: bool) -> bool {
matches!(command, CliCommand::Run | CliCommand::Demo) && !stdout_is_terminal
}
fn run_tui(paths: &Paths, loc: &Locale) -> anyhow::Result<ExitCode> {
let _instance = match instance::acquire() {
Ok(guard) => guard,
Err(instance::InstanceError::AlreadyRunning) => {
eprintln!("{}", loc.t("cli.instance.already_running"));
tracing::warn!("startup refused: another instance of the app is already running");
shared::console::hold_if_sole_owner(loc);
return Ok(ExitCode::from(2));
}
Err(instance::InstanceError::Init(e)) => {
return Err(anyhow!(
"{}",
loc.tf("cli.instance.init_failed", &[("err", e.as_str())])
));
}
};
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
root = %paths.root().display(),
"mindfork starting"
);
features::data_migration::run(paths, loc)?;
launch_tui(paths, Arc::new(LlamaSupervisor::new(paths)), true, loc)
}
fn run_demo(loc: &Locale, locale_warnings: &[String]) -> anyhow::Result<ExitCode> {
let root = std::env::temp_dir().join(format!("mindfork-demo-{}", std::process::id()));
let paths = Paths::with_root(&root);
paths.ensure_dirs(loc).with_context(|| {
loc.tf(
"cli.ctx.ensure_dirs",
&[("path", &root.display().to_string())],
)
})?;
let _logging =
logging::init(&paths, loc).with_context(|| loc.t("cli.ctx.init_logging").to_string())?;
for w in locale_warnings {
tracing::warn!("{w}");
}
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
root = %root.display(),
"mindfork demo starting"
);
{
let storage = Storage::open(paths.clone())
.with_context(|| loc.t("cli.ctx.open_storage").to_string())?;
features::demo::provision(&storage)
.with_context(|| loc.t("cli.ctx.provision_demo").to_string())?;
}
let backend = Arc::new(crate::shared::api::mock::MockBackend::cycling(
features::demo::demo_replies(),
DEMO_STREAM_DELAY_MS,
));
let result = launch_tui(&paths, Arc::new(DemoSupervisor::new(backend)), false, loc);
let _ = std::fs::remove_dir_all(&root);
result
}
const DEMO_STREAM_DELAY_MS: u64 = 18;
fn launch_tui(
paths: &Paths,
supervisor: Arc<dyn ServerSupervisor>,
apply_env: bool,
loc: &Locale,
) -> anyhow::Result<ExitCode> {
let background_query = crate::shared::osc11::begin();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.with_context(|| loc.t("cli.ctx.build_runtime").to_string())?;
let storage = Arc::new(
Storage::open(paths.clone()).with_context(|| loc.t("cli.ctx.open_storage").to_string())?,
);
let (cmd_tx, cmd_rx) = unbounded_channel::<AppCommand>();
let (evt_tx, evt_rx) = unbounded_channel::<AppEvent>();
let fresh_config = !paths.settings_file().exists();
let mut config = storage.json().load_config().unwrap_or_default();
if fresh_config {
config.interface.language = paths.default_language();
}
if apply_env {
apply_env_overrides(&mut config);
}
let orchestrator = runtime.spawn(orchestrator::run(OrchestratorDeps {
cmd_rx,
evt_tx: evt_tx.clone(),
storage,
config,
supervisor,
default_language: paths.default_language(),
extra_tools: Vec::new(),
}));
let dict_dir = paths.dictionaries_dir();
let bundled_dict_dir = paths.bundled_dictionaries_dir();
let personal = paths.personal_dictionary();
let result = app::runtime::run(
cmd_tx.clone(),
evt_rx,
dict_dir,
bundled_dict_dir,
personal,
paths.log_dir(),
background_query,
);
if result.is_err() && orchestrator.is_finished() {
match runtime.block_on(orchestrator) {
Err(join) if join.is_panic() => {
tracing::error!(error = %join, "the orchestrator task panicked");
}
Err(join) => tracing::error!(error = %join, "the orchestrator task ended abnormally"),
Ok(()) => tracing::error!("the orchestrator task ended while the session was running"),
}
}
let _ = cmd_tx.send(AppCommand::Quit);
runtime.shutdown_timeout(Duration::from_secs(2));
match &result {
Ok(()) => tracing::info!("mindfork exited cleanly"),
Err(err) => tracing::error!(error = %err, "mindfork exited with error"),
}
result.map(|()| ExitCode::SUCCESS)
}
fn cli_lang(settings_language: Option<Lang>, default_language: Lang) -> Lang {
settings_language.unwrap_or(default_language)
}
fn try_settings_language(settings_file: &Path) -> Option<Lang> {
let bytes = std::fs::read(settings_file).ok()?;
let cfg: AppConfig = serde_json::from_slice(&bytes).ok()?;
Some(cfg.interface.language)
}
fn print_error(lang: Lang, err: &anyhow::Error) {
eprintln!("{}", cli_error_line(i18n::locale(lang), err));
}
fn cli_error_line(loc: &Locale, err: &anyhow::Error) -> String {
format!("{}: {err:#}", loc.t("cli.err.prefix"))
}
fn acquire_cli_guard(loc: &Locale, action: &str) -> anyhow::Result<instance::InstanceGuard> {
match instance::acquire() {
Ok(guard) => Ok(guard),
Err(instance::InstanceError::AlreadyRunning) => {
bail!("{}", loc.tf("cli.guard.busy", &[("action", action)]))
}
Err(instance::InstanceError::Init(e)) => Err(anyhow!(
"{}",
loc.tf("cli.instance.init_failed", &[("err", e.as_str())])
)),
}
}
struct BackupConfig {
fs_root: Option<PathBuf>,
stored_password: Option<String>,
}
fn backup_config(paths: &Paths) -> BackupConfig {
let config = JsonStore::new(paths.clone())
.load_config()
.unwrap_or_default();
BackupConfig {
fs_root: config.tools.fs_root.map(PathBuf::from),
stored_password: crate::shared::secrets::stored_key(
&config.api_keys,
crate::shared::secrets::BACKUP_PASSWORD_KEY,
),
}
}
fn effective_password(arg: Option<String>, stored: Option<String>) -> Option<String> {
arg.or(stored).filter(|p| !p.is_empty())
}
fn run_backup(
paths: &Paths,
output: Option<PathBuf>,
compression: i64,
password: Option<String>,
loc: &Locale,
) -> anyhow::Result<()> {
let _instance = acquire_cli_guard(loc, loc.t("cli.guard.action.backup"))?;
let cfg = backup_config(paths);
let password = effective_password(password, cfg.stored_password);
let out = backup::create_backup(
paths,
output,
compression,
cfg.fs_root.as_deref(),
password.as_deref(),
loc,
|msg| println!("{msg}"),
);
features::terminal_input::discard_type_ahead();
let out = out.with_context(|| loc.t("cli.ctx.backup").to_string())?;
if password.is_some() {
println!("{}", loc.t("cli.backup.encrypted"));
}
println!(
"{}",
loc.tf(
"cli.backup.created",
&[("path", &out.display().to_string())]
)
);
Ok(())
}
fn run_stats(
paths: &Paths,
archive: Option<&Path>,
compare: Option<&Path>,
password: Option<String>,
json: bool,
loc: &Locale,
) -> anyhow::Result<ExitCode> {
use crate::features::data_stats::{self, OtherCopy};
let stats = match archive {
None => data_stats::collect_root(paths, loc)?,
Some(archive) => stats_of_archive(paths, archive, password.clone(), loc)?,
};
let Some(other) = compare else {
if json {
println!("{}", data_stats::render_json(&stats));
} else {
println!("{}", data_stats::render_text(&stats, loc));
}
return Ok(ExitCode::SUCCESS);
};
let (there, snapshot) = match data_stats::other_copy(other, loc)? {
OtherCopy::Archive => (stats_of_archive(paths, other, password, loc)?, None),
OtherCopy::Snapshot => (
data_stats::read_snapshot(other, loc)?,
Some(other.display().to_string()),
),
};
let comparison = data_stats::compare(&stats, &there, snapshot);
if json {
println!("{}", data_stats::render_comparison_json(&comparison));
} else {
println!("{}", data_stats::render_comparison_text(&comparison, loc));
}
Ok(ExitCode::SUCCESS)
}
fn stats_of_archive(
paths: &Paths,
archive: &Path,
password: Option<String>,
loc: &Locale,
) -> anyhow::Result<features::data_stats::DataStats> {
let stored = backup_config(paths).stored_password;
eprintln!(
"{}",
loc.tf(
"cli.stats.reading_archive",
&[("path", &archive.display().to_string())]
)
);
let password = resolve_restore_password(archive, password, stored, loc)?;
features::data_stats::collect_archive(archive, password.as_deref(), loc)
}
const PASSWORD_ATTEMPTS: usize = 3;
fn resolve_restore_password(
archive: &Path,
argument: Option<String>,
stored: Option<String>,
loc: &Locale,
) -> anyhow::Result<Option<String>> {
use crate::features::backup::ArchivePassword;
let from_settings = argument.is_none();
let mut current = effective_password(argument, stored);
for attempt in 0..PASSWORD_ATTEMPTS {
let Ok(status) = backup::check_password(archive, current.as_deref()) else {
return Ok(current);
};
if matches!(status, ArchivePassword::NotNeeded | ArchivePassword::Ok) {
return Ok(current);
}
let stored_failed = stored_password_failed(from_settings, attempt, status);
match ask_for_password(status, stored_failed, loc)? {
Some(entered) => current = Some(entered),
None => return Ok(current),
}
}
Ok(current)
}
fn ask_for_password(
status: crate::features::backup::ArchivePassword,
stored_failed: bool,
loc: &Locale,
) -> anyhow::Result<Option<String>> {
use crate::features::backup::ArchivePassword;
use crate::features::terminal_input;
if !terminal_input::is_interactive() {
if stored_failed {
bail!("{}", loc.t("cli.restore.stored_password_wrong"));
}
return Ok(None);
}
if stored_failed {
eprintln!("{}", loc.t("cli.restore.stored_password_wrong"));
} else if status == ArchivePassword::Wrong {
eprintln!("{}", loc.t("backup.err.wrong_password"));
}
Ok(terminal_input::read_password(
loc.t("cli.restore.password_prompt"),
)?)
}
fn stored_password_failed(
from_settings: bool,
attempt: usize,
status: crate::features::backup::ArchivePassword,
) -> bool {
from_settings && attempt == 0 && status == crate::features::backup::ArchivePassword::Wrong
}
fn run_restore(
paths: &Paths,
archive: &Path,
password: Option<String>,
loc: &Locale,
) -> anyhow::Result<()> {
let _instance = acquire_cli_guard(loc, loc.t("cli.guard.action.restore"))?;
let cfg = backup_config(paths);
let password = resolve_restore_password(archive, password, cfg.stored_password, loc)?;
if let Ok(Some(m)) = backup::read_manifest(archive)
&& m.is_newer_than_current()
{
eprintln!(
"{}",
loc.tf("backup.warn.newer_manifest", &[("version", &m.app_version)])
);
}
let outcome = backup::restore_backup(
paths,
archive,
cfg.fs_root.as_deref(),
password.as_deref(),
loc,
|msg| println!("{msg}"),
);
features::terminal_input::discard_type_ahead();
let outcome = outcome?;
match outcome {
RestoreOutcome::Restored { pre_restore } => {
if let Some(pre) = pre_restore {
println!(
"{}",
loc.tf(
"cli.restore.pre_saved",
&[("path", &pre.display().to_string())]
)
);
}
println!(
"{}",
loc.tf(
"cli.restore.done",
&[("path", &archive.display().to_string())]
)
);
Ok(())
}
RestoreOutcome::RolledBack {
pre_restore,
restore_error,
} => {
eprintln!(
"{}",
loc.tf(
"cli.restore.failed",
&[
("path", &archive.display().to_string()),
("err", &format!("{restore_error:#}")),
],
)
);
eprintln!(
"{}",
loc.tf(
"cli.restore.rolled_back",
&[("path", &pre_restore.display().to_string())]
)
);
bail!("{}", loc.t("cli.restore.err_rolled_back"))
}
RestoreOutcome::Failed {
pre_restore,
restore_error,
rollback_error,
} => {
eprintln!(
"{}",
loc.tf(
"cli.restore.failed",
&[
("path", &archive.display().to_string()),
("err", &format!("{restore_error:#}")),
],
)
);
if let Some(rb) = rollback_error {
eprintln!(
"{}",
loc.tf(
"cli.restore.rollback_failed",
&[("err", &format!("{rb:#}"))]
)
);
}
match pre_restore {
Some(pre) => bail!(
"{}",
loc.tf(
"cli.restore.err_inconsistent",
&[("path", &pre.display().to_string())]
)
),
None => bail!("{}", loc.t("cli.restore.err_failed")),
}
}
}
}
fn run_sandbox_setup(
paths: &Paths,
force: bool,
enable_python: bool,
loc: &Locale,
) -> anyhow::Result<()> {
let _instance = acquire_cli_guard(loc, loc.t("cli.guard.action.sandbox"))?;
let dir = paths.sandbox_dir();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.with_context(|| loc.t("cli.ctx.build_runtime").to_string())?;
runtime.block_on(features::sandbox_setup::setup(
&dir,
&features::sandbox_setup::SetupOptions { force },
loc,
|msg| println!("{msg}"),
))?;
if enable_python {
enable_python_tool(paths, loc)?;
println!("{}", loc.t("cli.sandbox.python_enabled"));
}
Ok(())
}
fn run_setup(paths: &Paths, args: &cli::SetupArgs, loc: &Locale) -> anyhow::Result<ExitCode> {
let loc: &'static Locale = i18n::locale(loc.lang());
if args.is_empty() {
println!("{}", cli::render_help(Some(cli::HelpTopic::Setup), loc));
return Ok(ExitCode::from(2));
}
let _instance = acquire_cli_guard(loc, loc.t("cli.guard.action.setup"))?;
Ok(ExitCode::from(setup_under_guard(paths, args, loc)?))
}
fn setup_under_guard(
paths: &Paths,
args: &cli::SetupArgs,
loc: &'static Locale,
) -> anyhow::Result<u8> {
let (store, mut config) = open_config_for_cli_write(paths, loc, SETUP_CTX)?;
let before = config.clone();
let wrote = features::provision::apply_settings(&mut config, args, loc)?;
let mut run = SetupRun {
paths,
loc,
runtime: cli_runtime(loc)?,
config,
wrote,
failed: Vec::new(),
};
if args.sandbox {
run.sandbox();
}
if let Some(backend) = &args.llama {
run.llama(backend, args.llama_build.clone());
}
run.write(&store, &before)?;
if args.verify {
run.verify();
}
Ok(run.summary())
}
const SETUP_CTX: &str = "cli.ctx.setup";
struct SetupRun<'a> {
paths: &'a Paths,
loc: &'static Locale,
runtime: tokio::runtime::Runtime,
config: AppConfig,
wrote: Vec<String>,
failed: Vec<&'static str>,
}
impl SetupRun<'_> {
fn fail(&mut self, step: &'static str, err: &anyhow::Error) {
let name = self.loc.t(step);
eprintln!(
"{}",
self.loc.tf(
"setup.step.failed",
&[("step", name), ("reason", &format!("{err:#}"))]
)
);
self.failed.push(name);
}
fn sandbox(&mut self) {
println!("{}", self.loc.t("setup.step.sandbox.header"));
let done = self.runtime.block_on(features::sandbox_setup::setup(
&self.paths.sandbox_dir(),
&features::sandbox_setup::SetupOptions { force: false },
self.loc,
|msg| println!("{msg}"),
));
match done {
Ok(()) if !self.config.tools.python_enabled => {
self.config.tools.python_enabled = true;
self.wrote.push(self.loc.tf(
"setup.settings.line",
&[("key", "tools.python_enabled"), ("value", "true")],
));
}
Ok(()) => {}
Err(e) => self.fail("setup.step.sandbox", &e),
}
}
fn llama(&mut self, backend: &str, build: Option<String>) {
println!("{}", self.loc.t("setup.step.llama.header"));
let done = self.runtime.block_on(features::llama_setup::setup(
&self.paths.llama_dir(),
&features::llama_setup::SetupOptions {
backend: backend.to_string(),
build,
force: false,
cudart: true,
},
self.loc,
|msg| println!("{msg}"),
));
match done {
Ok(_) => {
let cleared = features::provision::clear_dead_binaries(&mut self.config);
let loc = self.loc;
self.wrote.extend(
cleared
.into_iter()
.map(|key| loc.tf("setup.settings.cleared", &[("key", key)])),
);
}
Err(e) => self.fail("setup.step.llama", &e),
}
}
fn write(&self, store: &JsonStore, before: &AppConfig) -> anyhow::Result<()> {
if self.wrote.is_empty() {
return Ok(());
}
println!("{}", self.loc.t("setup.settings.header"));
for line in &self.wrote {
println!("{line}");
}
if self.config == *before {
println!("{}", self.loc.t("setup.settings.unchanged"));
return Ok(());
}
store
.save_config(&self.config)
.with_context(|| self.loc.t(SETUP_CTX).to_string())
}
fn verify(&mut self) {
println!("{}", self.loc.t("setup.step.verify.header"));
let lookup = app::supervisor::BinaryLookup::from_paths(self.paths);
let ok = self
.runtime
.block_on(app::verify::run(&self.config, &lookup, self.loc, |msg| {
println!("{msg}")
}));
if ok {
return;
}
self.failed.push(self.loc.t("setup.step.verify"));
println!(
"{}",
self.loc.tf(
"setup.verify.see_log",
&[("path", &self.paths.log_dir().display().to_string())]
)
);
}
fn summary(&self) -> u8 {
if self.failed.is_empty() {
println!("{}", self.loc.t("setup.summary.ok"));
return 0;
}
eprintln!(
"{}",
self.loc.tf(
"setup.summary.failed",
&[("steps", &self.failed.join(", "))]
)
);
1
}
}
fn cli_runtime(loc: &Locale) -> anyhow::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.with_context(|| loc.t("cli.ctx.build_runtime").to_string())
}
fn run_llama_backends(paths: &Paths, build: Option<&str>, loc: &Locale) -> anyhow::Result<()> {
let runtime = cli_runtime(loc)?;
let listing = runtime.block_on(features::llama_setup::list_backends(build, loc))?;
for line in features::llama_setup::render_backends(&listing, &paths.llama_dir(), loc) {
println!("{line}");
}
Ok(())
}
fn run_llama_setup(
paths: &Paths,
backend: Option<String>,
build: Option<String>,
cudart: bool,
force: bool,
set_binary: bool,
loc: &Locale,
) -> anyhow::Result<ExitCode> {
let root = paths.llama_dir();
let runtime = cli_runtime(loc)?;
let Some(backend) = backend else {
let listing =
runtime.block_on(features::llama_setup::list_backends(build.as_deref(), loc))?;
for line in features::llama_setup::render_backends(&listing, &root, loc) {
println!("{line}");
}
eprintln!("{}", loc.t("cli.llama.pick_backend"));
return Ok(ExitCode::from(2));
};
let _instance = acquire_cli_guard(loc, loc.t("cli.guard.action.llama"))?;
let installed = runtime.block_on(features::llama_setup::setup(
&root,
&features::llama_setup::SetupOptions {
backend,
build,
force,
cudart,
},
loc,
|msg| println!("{msg}"),
))?;
if set_binary {
for line in set_engine_binary(paths, &installed.binary, loc)? {
println!("{line}");
}
} else {
println!(
"{}",
loc.tf(
"cli.llama.binary_hint",
&[("path", &installed.binary.display().to_string())],
)
);
}
Ok(ExitCode::SUCCESS)
}
fn run_llama_remove(paths: &Paths, id: &str, force: bool, loc: &Locale) -> anyhow::Result<()> {
use crate::features::llama_setup as llama;
let root = paths.llama_dir();
let _instance = acquire_cli_guard(loc, loc.t("cli.guard.action.llama_remove"))?;
let all = llama::installed(&root);
let Some(install) = llama::find_install(&root, id) else {
let names: Vec<String> = all
.iter()
.map(|i| llama::install_name(&i.backend, &i.tag))
.collect();
if names.is_empty() {
bail!(
"{}",
loc.tf(
"llamacpp.remove.none",
&[("path", &root.display().to_string())]
)
);
}
let ambiguous: Vec<String> = all
.iter()
.filter(|i| i.backend == id)
.map(|i| llama::install_name(&i.backend, &i.tag))
.collect();
if ambiguous.len() > 1 {
bail!(
"{}",
loc.tf(
"llamacpp.remove.ambiguous",
&[("id", id), ("list", &ambiguous.join(", "))],
)
);
}
bail!(
"{}",
loc.tf(
"llamacpp.remove.unknown",
&[
("id", id),
("path", &root.display().to_string()),
("list", &names.join(", ")),
],
)
);
};
let config = JsonStore::new(paths.clone())
.load_config()
.unwrap_or_default();
let uses = llama::binary_uses(&config, &install.dir);
if !uses.is_empty() && !force {
bail!("{}", llama::render_in_use(&uses, &install, loc));
}
println!(
"{}",
loc.tf(
"llamacpp.remove.removing",
&[
("path", &install.dir.display().to_string()),
("size", &(install.bytes >> 20).to_string()),
],
)
);
llama::remove_install(&install.dir, loc)?;
for line in llama::render_removed(&install, &root, paths.exe_dir(), loc) {
println!("{line}");
}
Ok(())
}
fn set_engine_binary(paths: &Paths, binary: &Path, loc: &Locale) -> anyhow::Result<Vec<String>> {
let ctx = "cli.ctx.set_binary";
let (store, mut config) = open_config_for_cli_write(paths, loc, ctx)?;
let targets = features::llama_setup::set_engine_binary(&mut config, binary);
store
.save_config(&config)
.with_context(|| loc.t(ctx).to_string())?;
Ok(features::llama_setup::render_binary_targets(
&targets, binary, loc,
))
}
fn open_config_for_cli_write(
paths: &Paths,
loc: &Locale,
ctx_key: &str,
) -> anyhow::Result<(JsonStore, AppConfig)> {
features::data_migration::run(paths, loc)?;
let store = JsonStore::new(paths.clone());
let fresh = !paths.settings_file().exists();
let mut config = store
.load_config()
.with_context(|| loc.t(ctx_key).to_string())?;
if fresh {
config.interface.language = paths.default_language();
}
Ok((store, config))
}
fn run_llama_installed(paths: &Paths, loc: &Locale) {
let root = paths.llama_dir();
let found = features::llama_setup::installed(&root);
for line in features::llama_setup::render_installed(&found, &root, loc) {
println!("{line}");
}
}
fn enable_python_tool(paths: &Paths, loc: &Locale) -> anyhow::Result<()> {
let fresh = !paths.settings_file().exists();
let (store, mut config) = open_config_for_cli_write(paths, loc, "cli.ctx.enable_python")?;
if config.tools.python_enabled && !fresh {
return Ok(()); }
config.tools.python_enabled = true;
store
.save_config(&config)
.with_context(|| loc.t("cli.ctx.enable_python").to_string())
}
fn run_locales_export(code: &str, output: &Path, loc: &Locale) -> anyhow::Result<()> {
if output.exists() {
bail!(
"{}",
loc.tf(
"cli.locales.file_exists",
&[("path", &output.display().to_string())]
)
);
}
let lang = Lang::from_code(code);
let content = i18n::export_bundle(lang);
if let Some(parent) = output.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).with_context(|| {
loc.tf(
"cli.ctx.create_dir",
&[("path", &parent.display().to_string())],
)
})?;
}
std::fs::write(output, content).with_context(|| {
loc.tf(
"cli.ctx.write_file",
&[("path", &output.display().to_string())],
)
})?;
println!(
"{}",
loc.tf(
"cli.locales.exported",
&[("code", code), ("path", &output.display().to_string())]
)
);
Ok(())
}
fn run_import(paths: &Paths, file: &Path, loc: &Locale) -> anyhow::Result<()> {
features::data_migration::run(paths, loc)?;
let storage =
Storage::open(paths.clone()).with_context(|| loc.t("cli.ctx.open_storage").to_string())?;
let result = features::import::import_file(file, loc)
.with_context(|| loc.tf("cli.ctx.import", &[("file", &file.display().to_string())]))?;
for profile in &result.profiles {
storage.json().upsert_profile(profile)?;
}
for chat in &result.chats {
storage.json().save_chat(chat)?;
}
let mut config = storage.json().load_config().unwrap_or_default();
if let Some(sampling) = result.sampling {
config.default_sampling = sampling;
}
if let Some(interface) = result.interface {
if let Some(v) = interface.spellcheck_enabled {
config.interface.spellcheck_enabled = v;
}
if let Some(v) = interface.dictionaries {
config.interface.selected_dictionaries = v;
}
if let Some(v) = interface.theme {
config.interface.theme = v;
}
}
storage.json().save_config(&config)?;
println!(
"{}",
loc.tf(
"cli.import.done",
&[
("profiles", &result.profiles.len().to_string()),
("chats", &result.chats.len().to_string()),
]
)
);
Ok(())
}
fn apply_env_overrides(config: &mut AppConfig) {
apply_engine_env(config);
apply_embed_env(config);
}
fn apply_engine_env(config: &mut AppConfig) {
if let Ok(url) = std::env::var("MINDFORK_ENGINE_URL") {
config.engine.mode = ServerMode::External;
config.engine.external.url = Some(url);
} else if let Ok(bin) = std::env::var("MINDFORK_LLAMA_BIN") {
config.engine.mode = ServerMode::Managed;
config.engine.managed.binary = Some(bin);
if let Ok(m) = std::env::var("MINDFORK_MODEL") {
config.engine.managed.model_path = Some(m);
}
if let Ok(p) = std::env::var("MINDFORK_MMPROJ") {
config.engine.managed.mmproj = Some(p);
}
if let Some(ngl) = std::env::var("MINDFORK_NGL")
.ok()
.and_then(|v| v.parse().ok())
{
config.engine.managed.gpu_layers = ngl;
}
if let Some(ctx) = std::env::var("MINDFORK_CTX")
.ok()
.and_then(|v| v.parse().ok())
{
config.engine.managed.context_size = ctx;
}
if let Some(port) = std::env::var("MINDFORK_PORT")
.ok()
.and_then(|p| p.parse().ok())
{
config.engine.managed.port = port;
}
}
}
fn apply_embed_env(config: &mut AppConfig) {
if let Ok(url) = std::env::var("MINDFORK_EMBED_URL") {
config.embed.mode = ServerMode::External;
config.embed.external.url = Some(url);
} else if let Ok(bin) = std::env::var("MINDFORK_EMBED_BIN") {
config.embed.mode = ServerMode::Managed;
config.embed.managed.binary = Some(bin);
if let Ok(m) = std::env::var("MINDFORK_EMBED_MODEL") {
config.embed.managed.model_path = Some(m);
}
if let Some(port) = std::env::var("MINDFORK_EMBED_PORT")
.ok()
.and_then(|p| p.parse().ok())
{
config.embed.managed.port = port;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enable_python_on_a_fresh_root_keeps_the_installer_language() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path()).with_default_language(Lang::En);
assert!(!paths.settings_file().exists());
enable_python_tool(&paths, i18n::locale(Lang::En)).unwrap();
let cfg = JsonStore::new(paths.clone()).load_config().unwrap();
assert!(cfg.tools.python_enabled, "the tool was not enabled");
assert_eq!(
cfg.interface.language,
Lang::En,
"the installer's language choice was lost by creating settings.json"
);
}
fn setup_args_for(model: &Path) -> cli::SetupArgs {
cli::SetupArgs {
model: Some(model.to_path_buf()),
ctx: Some(4096),
set: vec![("engine.managed.sessions".into(), "2".into())],
..Default::default()
}
}
#[test]
fn setup_writes_once_and_a_rerun_touches_nothing() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path()).with_default_language(Lang::En);
let model = tmp.path().join("chat.gguf");
std::fs::write(&model, b"GGUF").unwrap();
let loc = i18n::locale(Lang::En);
let args = setup_args_for(&model);
assert_eq!(setup_under_guard(&paths, &args, loc).unwrap(), 0);
let cfg = JsonStore::new(paths.clone()).load_config().unwrap();
assert_eq!(cfg.engine.mode, ServerMode::Managed);
assert_eq!(
cfg.engine.managed.model_path.as_deref(),
Some(model.to_str().unwrap())
);
assert_eq!(cfg.engine.managed.context_size, 4096);
assert_eq!(cfg.engine.managed.sessions, 2);
assert_eq!(cfg.engine.managed.binary, None, "no binary path is written");
assert_eq!(cfg.interface.language, Lang::En, "the language was seeded");
let written = std::fs::read(paths.settings_file()).unwrap();
let bak = paths.settings_file().with_extension("bak");
assert!(!bak.exists(), "a first write has nothing to back up");
assert_eq!(setup_under_guard(&paths, &args, loc).unwrap(), 0);
assert!(!bak.exists(), "the re-run wrote: it rotated a backup");
assert_eq!(std::fs::read(paths.settings_file()).unwrap(), written);
}
#[test]
fn setup_refuses_a_typo_before_anything_is_written() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
let loc = i18n::locale(Lang::En);
let args = setup_args_for(&tmp.path().join("nope.gguf"));
let err = setup_under_guard(&paths, &args, loc).unwrap_err();
assert!(err.to_string().contains("nope.gguf"), "{err}");
assert!(!paths.settings_file().exists());
let args = cli::SetupArgs {
set: vec![("engine.managed.modle_path".into(), "x".into())],
..Default::default()
};
let err = setup_under_guard(&paths, &args, loc).unwrap_err();
assert!(err.to_string().contains("modle_path"), "{err}");
assert!(!paths.settings_file().exists());
}
#[test]
fn setup_with_nothing_to_do_exits_2_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
let code = run_setup(&paths, &cli::SetupArgs::default(), i18n::locale(Lang::En)).unwrap();
assert_eq!(format!("{code:?}"), format!("{:?}", ExitCode::from(2)));
assert!(!paths.settings_file().exists());
}
#[test]
fn setup_verify_skips_a_cloud_engine_and_fails_an_empty_managed_one() {
let loc = i18n::locale(Lang::En);
let cloud = tempfile::tempdir().unwrap();
let paths = Paths::with_root(cloud.path());
let args = cli::SetupArgs {
set: vec![
("engine.mode".into(), "claude".into()),
("embed.mode".into(), "openai".into()),
],
verify: true,
..Default::default()
};
assert_eq!(setup_under_guard(&paths, &args, loc).unwrap(), 0);
let empty = tempfile::tempdir().unwrap();
let paths = Paths::with_root(empty.path());
let args = cli::SetupArgs {
ctx: Some(2048),
verify: true,
..Default::default()
};
assert_eq!(setup_under_guard(&paths, &args, loc).unwrap(), 1);
let cfg = JsonStore::new(paths.clone()).load_config().unwrap();
assert_eq!(cfg.engine.managed.context_size, 2048);
}
#[test]
fn a_failed_setup_step_is_remembered_not_raised() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
let loc = i18n::locale(Lang::En);
let mut run = SetupRun {
paths: &paths,
loc,
runtime: cli_runtime(loc).unwrap(),
config: AppConfig::default(),
wrote: Vec::new(),
failed: Vec::new(),
};
assert_eq!(run.summary(), 0);
run.fail("setup.step.sandbox", &anyhow!("the network went away"));
assert_eq!(run.failed, [loc.t("setup.step.sandbox")]);
assert_eq!(run.summary(), 1);
run.write(&JsonStore::new(paths.clone()), &AppConfig::default())
.unwrap();
assert!(!paths.settings_file().exists());
}
#[test]
fn enable_python_preserves_an_existing_config() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
let store = JsonStore::new(paths.clone());
let mut cfg = AppConfig::default();
cfg.interface.language = Lang::En;
cfg.max_tool_rounds = 7;
store.save_config(&cfg).unwrap();
enable_python_tool(&paths, i18n::locale(Lang::Ru)).unwrap();
let after = store.load_config().unwrap();
assert!(after.tools.python_enabled);
assert_eq!(after.max_tool_rounds, 7, "an unrelated setting was reset");
assert_eq!(
after.interface.language,
Lang::En,
"an existing language was overwritten by the defaults.json seeding"
);
}
#[test]
fn enable_python_is_a_no_op_when_already_enabled() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
let minimal = format!(
r#"{{"schema_version":{},"tools":{{"python_enabled":true}}}}"#,
crate::shared::config::SCHEMA_VERSION
)
.into_bytes();
std::fs::write(paths.settings_file(), &minimal).unwrap();
enable_python_tool(&paths, i18n::locale(Lang::Ru)).unwrap();
assert_eq!(
std::fs::read(paths.settings_file()).unwrap(),
minimal,
"settings.json was rewritten (and a hand-edited file expanded) for nothing"
);
}
#[test]
fn enable_python_refuses_a_corrupt_config_without_touching_it() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
std::fs::write(paths.settings_file(), b"{ this is not json").unwrap();
assert!(enable_python_tool(&paths, i18n::locale(Lang::Ru)).is_err());
assert_eq!(
std::fs::read(paths.settings_file()).unwrap(),
b"{ this is not json",
"a corrupt config was overwritten instead of being reported"
);
}
#[test]
fn enable_python_refuses_a_config_from_a_newer_version() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path());
let from_the_future = format!(
r#"{{"schema_version":{},"tools":{{"python_enabled":false}},"a_setting_we_do_not_know":42}}"#,
crate::shared::storage::schema::SETTINGS_SCHEMA + 1
);
std::fs::write(paths.settings_file(), &from_the_future).unwrap();
assert!(enable_python_tool(&paths, i18n::locale(Lang::Ru)).is_err());
assert_eq!(
std::fs::read_to_string(paths.settings_file()).unwrap(),
from_the_future,
"a newer config was rewritten, dropping the fields this build cannot see"
);
}
#[test]
fn stats_is_answered_before_the_data_root_is_created() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::with_root(tmp.path().join("never-created"));
let command = CliCommand::Stats {
archive: None,
compare: None,
password: None,
json: true,
};
real_main(command, &paths, i18n::locale(Lang::En), &[]).unwrap();
assert!(!paths.root().exists());
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
#[test]
fn only_an_untyped_first_attempt_blames_the_stored_password() {
use crate::features::backup::ArchivePassword::{Required, Wrong};
assert!(stored_password_failed(true, 0, Wrong));
assert!(
!stored_password_failed(false, 0, Wrong),
"--password was given"
);
assert!(
!stored_password_failed(true, 1, Wrong),
"typed at the prompt"
);
assert!(
!stored_password_failed(true, 0, Required),
"nothing was tried"
);
}
#[test]
fn effective_password_prefers_the_argument_then_the_setting() {
let arg = || Some("from-arg".to_string());
let stored = || Some("from-settings".to_string());
assert_eq!(
effective_password(arg(), stored()).as_deref(),
Some("from-arg")
);
assert_eq!(
effective_password(None, stored()).as_deref(),
Some("from-settings")
);
assert_eq!(effective_password(arg(), None).as_deref(), Some("from-arg"));
assert_eq!(effective_password(None, None), None);
assert_eq!(effective_password(Some(String::new()), None), None);
assert_eq!(effective_password(None, Some(String::new())), None);
assert_eq!(effective_password(Some(String::new()), stored()), None);
}
#[test]
fn only_a_full_screen_launch_without_a_terminal_is_refused() {
for command in [CliCommand::Run, CliCommand::Demo] {
assert!(refuses_tui_launch(&command, false), "{command:?}");
assert!(!refuses_tui_launch(&command, true), "{command:?}");
}
for command in [
CliCommand::LlamaInstalled,
CliCommand::Import {
file: PathBuf::from("chats.json"),
},
] {
assert!(!refuses_tui_launch(&command, false), "{command:?}");
}
}
#[test]
fn cli_lang_prefers_settings_then_resolved_default() {
assert_eq!(cli_lang(Some(Lang::Ru), Lang::En), Lang::Ru);
assert_eq!(cli_lang(Some(Lang::En), Lang::Ru), Lang::En);
assert_eq!(cli_lang(None, Lang::Ru), Lang::Ru);
assert_eq!(cli_lang(None, Lang::En), Lang::En);
}
#[test]
fn cli_error_line_is_localized_single_line() {
let err = anyhow!("outer").context("wrapper");
let en = cli_error_line(i18n::locale(Lang::En), &err);
assert!(en.starts_with("Error: "), "{en}");
assert!(en.contains("wrapper") && en.contains("outer"), "{en}");
assert!(!en.contains('\n'), "{en}");
let ru = cli_error_line(i18n::locale(Lang::Ru), &err);
assert!(ru.starts_with("Ошибка: "), "{ru}");
}
}