use crate::Error;
use crate::dir_context::PathResolver;
use crate::hub::get_hub;
use crate::runtime::Runtime;
use crate::script::aip_modules::support::{check_access_delete, check_access_write, process_path_reference};
use crate::support::files::safer_trash_file;
use crate::support::text::{ensure_single_trailing_newline, trim_end_if_needed, trim_start_if_needed};
use crate::types::{FileInfo, FileOverOptions, SaveOptions};
use mlua::{FromLua, IntoLua, Lua, Value};
use simple_fs::ensure_file_dir;
use std::fs::{File, write};
use std::io::Write;
pub(super) fn file_save(
lua: &Lua,
runtime: &Runtime,
rel_path: String,
mut content: String,
options: Option<SaveOptions>,
) -> mlua::Result<mlua::Value> {
let dir_context = runtime.dir_context();
let full_path = dir_context.resolve_path(runtime.session(), (&rel_path).into(), PathResolver::WksDir, None)?;
if let Some(opts) = options
&& !opts.is_empty()
{
if opts.should_trim_start() {
content = trim_start_if_needed(content);
}
if opts.should_trim_end() {
content = trim_end_if_needed(content);
}
if opts.should_single_trailing_newline() {
content = ensure_single_trailing_newline(content);
}
}
let wks_dir = dir_context.try_wks_dir_with_err_ctx("aip.file.save requires a aipack workspace setup")?;
check_access_write(&full_path, wks_dir)?;
ensure_file_dir(&full_path).map_err(Error::from)?;
write(&full_path, content).map_err(|err| Error::custom(format!("Fail to save file {rel_path}.\nCause {err}")))?;
let rel_path = full_path.diff(wks_dir).unwrap_or_else(|| full_path.clone());
get_hub().publish_sync(format!("-> Lua aip.file.save called on: {rel_path}"));
let file_info = FileInfo::new(runtime.dir_context(), full_path, true);
let file_info = file_info.into_lua(lua)?;
Ok(file_info)
}
pub(super) fn file_move(
lua: &Lua,
runtime: &Runtime,
src_path: String,
dest_path: String,
options: Option<FileOverOptions>,
) -> mlua::Result<mlua::Value> {
let dir_context = runtime.dir_context();
let options = options.unwrap_or_default();
let src_full = process_path_reference(runtime, &src_path)?;
let dest_full = process_path_reference(runtime, &dest_path)?;
let wks_dir = dir_context.try_wks_dir_with_err_ctx("aip.file.move requires a aipack workspace setup")?;
check_access_delete(&src_full, wks_dir)?;
check_access_write(&dest_full, wks_dir)?;
if !src_full.exists() {
return Err(Error::custom(format!("Move file failed - Source `{src_path}` does not exist")).into());
}
if !options.overwrite() && dest_full.exists() {
return Err(Error::custom(format!(
"Move file failed - Destination `{dest_path}` already exists and overwrite is set to false.\nUse `aip.file.move(src_path, dst_path,{{overwrite = true}}` to allow overwrite."
))
.into());
}
ensure_file_dir(&dest_full).map_err(Error::from)?;
std::fs::rename(&src_full, &dest_full)
.map_err(|err| Error::custom(format!("Fail to move from `{src_path}` to `{dest_path}`.\nCause {err}")))?;
let rel_dest = dest_full.diff(wks_dir).unwrap_or_else(|| dest_full.clone());
get_hub().publish_sync(format!("-> Lua aip.file.move called to: {rel_dest}"));
let file_info = FileInfo::new(runtime.dir_context(), dest_full, true);
let file_info = file_info.into_lua(lua)?;
Ok(file_info)
}
pub(super) fn file_copy(
lua: &Lua,
runtime: &Runtime,
src_path: String,
dest_path: String,
options: Option<FileOverOptions>,
) -> mlua::Result<mlua::Value> {
let dir_context = runtime.dir_context();
let options = options.unwrap_or_default();
let src_full = process_path_reference(runtime, &src_path)?;
let dest_full = process_path_reference(runtime, &dest_path)?;
let wks_dir = dir_context.try_wks_dir_with_err_ctx("aip.file.copy requires a aipack workspace setup")?;
check_access_write(&dest_full, wks_dir)?;
if !options.overwrite() && dest_full.exists() {
return Err(Error::custom(format!(
"Copy file failed - Destination `{dest_path}` already exists and overwrite is set to false.\nUse `aip.file.copy(src_path, dst_path,{{overwrite = true}}` to allow overwrite."
))
.into());
}
ensure_file_dir(&dest_full).map_err(Error::from)?;
let mut src_file = File::open(&src_full)
.map_err(|err| Error::custom(format!("Fail to open source file `{src_path}` for copy.\nCause {err}")))?;
let mut dest_file = File::create(&dest_full).map_err(|err| {
Error::custom(format!(
"Fail to create destination file `{dest_path}` for copy.\nCause {err}"
))
})?;
std::io::copy(&mut src_file, &mut dest_file)
.map_err(|err| Error::custom(format!("Fail to copy from `{src_path}` to `{dest_path}`.\nCause {err}")))?;
let rel_dest = dest_full.diff(wks_dir).unwrap_or_else(|| dest_full.clone());
get_hub().publish_sync(format!("-> Lua aip.file.copy called to: {rel_dest}"));
let file_info = FileInfo::new(runtime.dir_context(), dest_full, true);
let file_info = file_info.into_lua(lua)?;
Ok(file_info)
}
pub(super) fn file_delete(lua: &Lua, runtime: &Runtime, rel_path: String) -> mlua::Result<mlua::Value> {
let dir_context = runtime.dir_context();
let full_path = dir_context.resolve_path(runtime.session(), (&rel_path).into(), PathResolver::WksDir, None)?;
let wks_dir = dir_context.try_wks_dir_with_err_ctx("aip.file.delete requires a aipack workspace setup")?;
check_access_delete(&full_path, wks_dir)?;
let removed = if full_path.exists() {
safer_trash_file(&full_path, None)?
} else {
false
};
if removed {
let rel_path = full_path.diff(wks_dir).unwrap_or_else(|| full_path.clone());
get_hub().publish_sync(format!("-> Lua aip.file.delete called on: {rel_path}"));
}
removed.into_lua(lua)
}
pub(super) fn file_append(
lua: &Lua,
runtime: &Runtime,
rel_path: String,
content: String,
) -> mlua::Result<mlua::Value> {
let dir_context = runtime.dir_context();
let full_path = dir_context.resolve_path(runtime.session(), (&rel_path).into(), PathResolver::WksDir, None)?;
let lock_handle = runtime.file_write_manager().lock_for_path(&full_path);
let _guard = lock_handle.lock();
let wks_dir = dir_context.try_wks_dir_with_err_ctx("aip.file.append requires a aipack workspace setup")?;
check_access_write(&full_path, wks_dir)?;
check_access_write(&full_path, wks_dir)?;
ensure_file_dir(&full_path).map_err(Error::from)?;
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&full_path)
.map_err(Error::from)?;
file.write_all(content.as_bytes())?;
let file_info = FileInfo::new(runtime.dir_context(), full_path, true);
let file_info = file_info.into_lua(lua)?;
Ok(file_info)
}
pub(super) fn file_ensure_exists(
lua: &Lua,
runtime: &Runtime,
path: String,
content: Option<String>,
options: Option<EnsureExistsOptions>,
) -> mlua::Result<mlua::Value> {
let options = options.unwrap_or_default();
let rel_path = simple_fs::SPath::new(path);
let full_path =
runtime
.dir_context()
.resolve_path(runtime.session(), rel_path.clone(), PathResolver::WksDir, None)?;
let lock_handle = runtime.file_write_manager().lock_for_path(&full_path);
let _guard = lock_handle.lock();
if !full_path.exists() {
simple_fs::ensure_file_dir(&full_path).map_err(|err| Error::custom(err.to_string()))?;
let content = content.unwrap_or_default();
write(&full_path, content)?;
}
else if options.content_when_empty && crate::support::files::is_file_empty(&full_path)? {
let content = content.unwrap_or_default();
write(&full_path, content)?;
}
let file_info = FileInfo::new(runtime.dir_context(), rel_path, &full_path);
file_info.into_lua(lua)
}
pub(super) fn file_ensure_dir(lua: &Lua, runtime: &Runtime, path: String) -> mlua::Result<mlua::Value> {
let dir_context = runtime.dir_context();
let full_path = process_path_reference(runtime, &path)?;
let lock_handle = runtime.file_write_manager().lock_for_path(&full_path);
let _guard = lock_handle.lock();
let wks_dir = dir_context.try_wks_dir_with_err_ctx("aip.file.ensure_dir requires a aipack workspace setup")?;
check_access_write(&full_path, wks_dir)?;
if full_path.exists() {
if !full_path.is_dir() {
return Err(Error::custom(format!(
"Ensure dir failed - Path `{path}` already exists and is not a directory"
))
.into());
}
return false.into_lua(lua);
}
std::fs::create_dir_all(&full_path)
.map_err(|err| Error::custom(format!("Fail to ensure dir `{path}`.\nCause {err}")))?;
true.into_lua(lua)
}
#[derive(Debug, Default)]
pub struct EnsureExistsOptions {
pub content_when_empty: bool,
}
impl FromLua for EnsureExistsOptions {
fn from_lua(value: Value, _lua: &Lua) -> mlua::Result<Self> {
let table = value
.as_table()
.ok_or(crate::Error::custom("EnsureExistsOptions should be a table"))?;
let set_content_when_empty = table.get("content_when_empty")?;
Ok(Self {
content_when_empty: set_content_when_empty,
})
}
}
#[cfg(test)]
mod tests {
type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>;
use crate::_test_support::{assert_contains, run_reflective_agent};
use crate::runtime::Runtime;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_simple_ok() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_save_simple_ok.md");
let fx_content = "hello from test_file_save_simple_ok";
let _res = run_reflective_agent(
&format!(r#"return aip.file.save("{fx_dest_path}", "{fx_content}");"#),
None,
)
.await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_ok_in_base() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.aipack_paths()
.aipack_base_dir()
.join(".tmp/test_lua_file_save_ok_in_base.md");
let fx_content = "hello from test_lua_file_save_ok_in_base";
let _res = run_reflective_agent(
&format!(r#"return aip.file.save("{fx_dest_path}", "{fx_content}");"#),
None,
)
.await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_err_out_workspace() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join("../.tmp/test_lua_file_save_err_out_workspace.md");
let fx_content = "hello from test_lua_file_save_err_out_workspace";
let res = run_reflective_agent(
&format!(r#"return aip.file.save("{fx_dest_path}", "{fx_content}");"#),
None,
)
.await;
let Err(err) = res else { panic!("Should return error") };
assert!(err.to_string().contains("does not belong to the workspace dir"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_with_trim_start() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_save_with_trim_start.md");
let fx_content_in = " Leading spaces and content.\\n";
let fx_content_expected = "Leading spaces and content.\n";
let fx_options = "{trim_start = true}";
let lua_code = format!(
r#"return aip.file.save("{}", "{}", {});"#,
fx_dest_path, fx_content_in, fx_options
);
let _res = run_reflective_agent(&lua_code, None).await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content_expected);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_with_trim_end() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_save_with_trim_end.md");
let fx_content_in = "Content and trailing spaces. \\n\\t";
let fx_content_expected = "Content and trailing spaces.";
let fx_options = "{trim_end = true}";
let lua_code = format!(
r#"return aip.file.save("{}", "{}", {});"#,
fx_dest_path, fx_content_in, fx_options
);
let _res = run_reflective_agent(&lua_code, None).await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content_expected);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_with_single_trailing_newline_add() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_save_with_single_trailing_newline_add.md");
let fx_content_in = "Content without newline";
let fx_content_expected = "Content without newline\n";
let fx_options = "{single_trailing_newline = true}";
let lua_code = format!(
r#"return aip.file.save("{}", "{}", {});"#,
fx_dest_path, fx_content_in, fx_options
);
let _res = run_reflective_agent(&lua_code, None).await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content_expected);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_with_single_trailing_newline_remove_extra() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_save_with_single_trailing_newline_remove_extra.md");
let fx_content_in = "Content with multiple newlines\\n\\n\\n"; let fx_content_expected = "Content with multiple newlines\n"; let fx_options = "{single_trailing_newline = true}";
let lua_code = format!(
r#"return aip.file.save("{}", "{}", {});"#,
fx_dest_path, fx_content_in, fx_options
);
let _res = run_reflective_agent(&lua_code, None).await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content_expected);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_save_with_combo_options() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_save_with_combo_options.md");
let fx_content_in = " Content with all trims and newlines. \\n\\n";
let fx_content_expected = "Content with all trims and newlines.\n";
let fx_options = "{trim_start = true, trim_end = true, single_trailing_newline = true}";
let lua_code = format!(
r#"return aip.file.save("{}", "{}", {});"#,
fx_dest_path, fx_content_in, fx_options
);
let _res = run_reflective_agent(&lua_code, None).await?;
let file_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(file_content, fx_content_expected);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_copy_simple_ok() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_src_path = "agent-script/agent-hello.aip";
let fx_dest_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_copy_simple_ok.aip");
let _res = run_reflective_agent(
&format!(r#"return aip.file.copy("{fx_src_path}", "{fx_dest_path}");"#),
None,
)
.await?;
assert!(fx_dest_path.exists());
let src_content = std::fs::read_to_string(fx_src_path)?;
let dest_content = std::fs::read_to_string(fx_dest_path)?;
assert_eq!(src_content, dest_content);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_move_simple_ok() -> Result<()> {
let fx_src = ".tmp/move_src.txt";
let fx_dest = ".tmp/move_dest.txt";
let fx_content = "move content";
let res = run_reflective_agent(
&format!(
r#"
aip.file.save("{fx_src}", "{fx_content}")
local info = aip.file.move("{fx_src}", "{fx_dest}")
return {{
exists_src = aip.file.exists("{fx_src}"),
exists_dest = aip.file.exists("{fx_dest}"),
dest_path = info.path
}}
"#
),
None,
)
.await?;
assert!(!res.x_get_bool("exists_src")?);
assert!(res.x_get_bool("exists_dest")?);
assert_contains(res.x_get_str("dest_path")?, fx_dest);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_lua_file_tmp_with_ctx() -> Result<()> {
let fx_content = "Hello tmp content";
let fx_path = "my-dir/some-tmp-file.aip";
let fx_code = format!(
r#"
local path = CTX.TMP_DIR .. "/{fx_path}"
aip.file.save(path,"{fx_content}")
return {{
file = aip.file.load(path),
session = CTX.SESSION_UID
}}
"#
);
let res = run_reflective_agent(&fx_code, None).await?;
let content = res.x_get_str("/file/content")?;
let path = res.x_get_str("/file/path")?;
let session = res.x_get_str("session")?;
assert_eq!(content, fx_content);
assert_ends_with(path, &format!(".aipack/.session/{session}/tmp/{fx_path}"));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_lua_file_tmp_with_var() -> Result<()> {
let fx_content = "Hello tmp content";
let fx_path = "my-dir/tmp_with_var_file.txt";
let fx_tmp_path = format!("$tmp/{fx_path}");
let fx_code = format!(
r#"
local path = "{fx_tmp_path}"
aip.file.save(path,"{fx_content}")
local files = aip.file.list_load("$tmp/**/*.*")
return {{
file = aip.file.load(path),
files = files,
session = CTX.SESSION_UID
}}
"#
);
let res = run_reflective_agent(&fx_code, None).await?;
let content = res.x_get_str("/file/content")?;
let path = res.x_get_str("/file/path")?;
let file_name = res.x_get_str("/file/name")?;
assert_eq!(content, fx_content);
assert_eq!(file_name, "tmp_with_var_file.txt");
assert_ends_with(path, &fx_tmp_path);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_ensure_dir_create_ok() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dir_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_ensure_dir_create_ok/nested/dir");
let res = run_reflective_agent(&format!(r#"return aip.file.ensure_dir("{fx_dir_path}");"#), None).await?;
assert!(res.as_bool().ok_or("Should return bool")?);
assert!(fx_dir_path.exists());
assert!(fx_dir_path.is_dir());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_ensure_dir_existing_dir_returns_false() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_dir_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_ensure_dir_existing_dir_returns_false");
std::fs::create_dir_all(&fx_dir_path)?;
let res = run_reflective_agent(&format!(r#"return aip.file.ensure_dir("{fx_dir_path}");"#), None).await?;
assert!(!res.as_bool().ok_or("Should return bool")?);
assert!(fx_dir_path.exists());
assert!(fx_dir_path.is_dir());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lua_file_ensure_dir_err_when_file_exists() -> Result<()> {
let runtime = Runtime::new_test_runtime_sandbox_01().await?;
let dir_context = runtime.dir_context();
let fx_file_path = dir_context
.wks_dir()
.ok_or("Should have workspace setup")?
.join(".tmp/test_lua_file_ensure_dir_err_when_file_exists.txt");
std::fs::write(&fx_file_path, "some content")?;
let res = run_reflective_agent(&format!(r#"return aip.file.ensure_dir("{fx_file_path}");"#), None).await;
let Err(err) = res else { panic!("Should return error") };
assert_contains(&err.to_string(), "already exists and is not a directory");
Ok(())
}
use crate::_test_support::assert_ends_with;
use value_ext::JsonValueExt as _;
}