use std::path::Path;
use std::path::PathBuf;
use clap::Parser;
use clap::Subcommand;
use tracing::log;
use quilt_rs::flow::UserMeta;
use quilt_rs::io::remote::WorkflowIntent;
use quilt_uri::Host;
use quilt_uri::Namespace;
mod browse;
mod commit;
mod create;
mod history;
mod install;
mod list;
mod login;
mod model;
mod output;
mod pull;
mod push;
mod role;
mod status;
mod text;
mod undo_commit;
mod uninstall;
#[cfg(test)]
mod fixtures;
use model::Model;
pub use output::Std;
pub use output::print;
const DOMAIN_DIR_NAMESPACE: &str = "com.quiltdata.quilt-sync";
fn commit_workflow_intent(
workflow: Option<&str>,
no_workflow: bool,
) -> Result<WorkflowIntent, Error> {
if no_workflow {
Ok(WorkflowIntent::NoWorkflow)
} else if matches!(workflow, Some(id) if id.trim().is_empty()) {
Err(Error::WorkflowEmpty)
} else {
Ok(WorkflowIntent::from_optional_id(workflow))
}
}
fn parse_optional_namespace(namespace: Option<String>) -> Result<Option<Namespace>, Error> {
Ok(match namespace {
Some(namespace) => Some(namespace.try_into()?),
None => None,
})
}
fn get_domain_dir(dir_arg: Option<PathBuf>) -> Result<PathBuf, Error> {
match dir_arg {
Some(user_specified_dir) => Ok(user_specified_dir),
None => match dirs::data_local_dir() {
Some(default_user_dir) => Ok(default_user_dir.join(DOMAIN_DIR_NAMESPACE)),
None => Err(Error::Domain),
},
}
}
fn get_default_home_dir() -> Result<PathBuf, Error> {
dirs::home_dir()
.map(|user_home| user_home.join(quilt_rs::DEFAULT_HOME_DIR_NAME))
.ok_or(Error::Home)
}
async fn initialize_home(model: &Model, home: Option<PathBuf>) -> Result<(), Error> {
if let Some(dir) = home {
model.set_home(dir).await?;
} else {
match model.get_home().await {
Ok(_) => {}
Err(Error::Quilt(quilt_rs::Error::Lineage(
quilt_rs::LineageError::Missing | quilt_rs::LineageError::MissingHome,
))) => {
model.set_home(get_default_home_dir()?).await?;
}
Err(err) => return Err(err),
}
}
model.get_home().await?;
Ok(())
}
fn namespace_from_working_dir(home: &Path, current_dir: &Path) -> Result<Namespace, Error> {
let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
let current_dir =
std::fs::canonicalize(current_dir).unwrap_or_else(|_| current_dir.to_path_buf());
let mut components = current_dir
.strip_prefix(&home)
.map_err(|_| Error::NamespaceRequired)?
.components();
let prefix = components
.next()
.and_then(|component| component.as_os_str().to_str())
.ok_or(Error::NamespaceRequired)?;
let name = components
.next()
.and_then(|component| component.as_os_str().to_str())
.ok_or(Error::NamespaceRequired)?;
Namespace::try_from(format!("{prefix}/{name}")).map_err(Error::from)
}
async fn resolve_namespace(model: &Model, namespace: Option<String>) -> Result<Namespace, Error> {
if let Some(namespace) = namespace {
return Ok(namespace.try_into()?);
}
let home = model.get_home().await?;
let current_dir = std::env::current_dir()?;
namespace_from_working_dir(home.as_ref(), ¤t_dir)
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[command(subcommand)]
command: Commands,
#[arg(long)]
home: Option<PathBuf>,
#[arg(short, long)]
domain: Option<PathBuf>,
#[arg(short, long, global = true)]
pub(crate) verbose: bool,
}
#[derive(clap::Args, Debug)]
struct PackageRef {
#[arg(short, long)]
namespace: Option<String>,
}
impl PackageRef {
async fn resolve(self, model: &Model) -> Result<Namespace, Error> {
resolve_namespace(model, self.namespace).await
}
}
#[derive(Subcommand, Debug)]
enum Commands {
Browse {
#[arg(value_name = "PKG_URI")]
uri: String,
},
Create {
#[arg(short, long)]
namespace: String,
#[arg(short, long)]
source: Option<PathBuf>,
#[arg(short, long)]
message: Option<String>,
},
Commit {
#[arg(short, long)]
message: String,
#[arg(short, long)]
user_meta: Option<String>,
#[command(flatten)]
pkg: PackageRef,
#[arg(short, long)]
workflow: Option<String>,
#[arg(long, conflicts_with = "workflow")]
no_workflow: bool,
},
Install {
#[arg(value_name = "PKG_URI")]
uri: String,
#[arg(short, long)]
namespace: Option<String>,
#[arg(short, long)]
path: Option<Vec<PathBuf>>,
},
Login {
#[arg(short, long)]
code: Option<String>,
#[arg(long)]
host: Host,
},
List {
#[arg(long)]
json: bool,
},
Log {
#[command(flatten)]
pkg: PackageRef,
},
Pull {
#[command(flatten)]
pkg: PackageRef,
},
Push {
#[command(flatten)]
pkg: PackageRef,
#[arg(short, long, requires = "origin")]
bucket: Option<String>,
#[arg(short, long, requires = "bucket")]
origin: Option<Host>,
#[arg(short, long)]
workflow: Option<String>,
#[arg(long, conflicts_with = "workflow")]
no_workflow: bool,
},
Role {
#[arg(long)]
host: Host,
#[arg(long, value_name = "ROLE")]
set: Option<String>,
},
Status {
#[command(flatten)]
pkg: PackageRef,
#[arg(long)]
json: bool,
},
UndoCommit {
#[command(flatten)]
pkg: PackageRef,
},
Uninstall {
#[command(flatten)]
pkg: PackageRef,
},
}
#[allow(
clippy::too_many_lines,
reason = "cohesive top-level CLI command dispatch"
)]
pub async fn init(args: Args) -> Result<Std, Error> {
let root_dir = get_domain_dir(args.domain)?;
let m = Model::from(root_dir);
initialize_home(&m, args.home).await?;
match args.command {
Commands::Browse { uri } => {
let args = browse::Input { uri };
log::debug!("Browsing {args:?}");
Ok(browse::command(m, args).await)
}
Commands::Create {
namespace,
source,
message,
} => {
let args = create::Input {
namespace: namespace.try_into()?,
source,
message,
};
log::debug!("Creating {args:?}");
Ok(create::command(m, args).await)
}
Commands::Commit {
pkg,
message,
user_meta,
workflow,
no_workflow,
} => {
let namespace = pkg.resolve(&m).await?;
let user_meta = match &user_meta {
Some(object) => match serde_json::from_str(object)? {
serde_json::Value::Object(object) => {
UserMeta::Set(serde_json::Value::Object(object))
}
_ => {
return Err(Error::CommitMetaInvalid(object.clone()));
}
},
None => UserMeta::Keep,
};
let workflow = commit_workflow_intent(workflow.as_deref(), no_workflow)?;
let args = commit::Input {
message,
namespace,
user_meta,
workflow,
host_config: None,
};
log::debug!("Committing {args:?}");
Ok(commit::command(m, args).await)
}
Commands::Install {
namespace,
path,
uri,
} => {
let args = install::Input {
namespace: parse_optional_namespace(namespace)?,
paths: path,
uri,
};
log::debug!("Installing {args:?}");
Ok(install::command(m, args).await)
}
Commands::Login { code, host } => {
if let Some(code) = code {
let args = login::Input { code, host };
log::debug!("Logging in {args:?}");
Ok(login::command(m, args).await)
} else {
Ok(Std::Err(Error::LoginRequired(host)))
}
}
Commands::List { json } => {
log::info!("Listing installed packages");
Ok(list::command(m, json).await)
}
Commands::Log { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = history::Input { namespace };
log::debug!("Logging {args:?}");
Ok(history::command(m, args).await)
}
Commands::Pull { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = pull::Input {
namespace,
host_config: None,
};
log::debug!("Pull {args:?}");
Ok(pull::command(m, args).await)
}
Commands::Push {
pkg,
bucket,
origin,
workflow,
no_workflow,
} => {
let namespace = pkg.resolve(&m).await?;
if bucket.is_none() && (workflow.is_some() || no_workflow) {
return Err(Error::WorkflowRequiresBucket);
}
let workflow = commit_workflow_intent(workflow.as_deref(), no_workflow)?;
let args = push::Input {
namespace,
host_config: None,
bucket,
origin,
workflow,
};
log::debug!("Pushing {args:?}");
Ok(push::command(m, args).await)
}
Commands::Role { host, set } => {
let args = role::Input { host, set };
log::debug!("Role {args:?}");
Ok(role::command(m, args).await)
}
Commands::Status { pkg, json } => {
let namespace = pkg.resolve(&m).await?;
let args = status::Input {
namespace,
host_config: None,
};
log::debug!("Status {args:?}");
Ok(status::command(m, args, json).await)
}
Commands::UndoCommit { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = undo_commit::Input { namespace };
log::debug!("Undoing commit {args:?}");
Ok(undo_commit::command(m, args).await)
}
Commands::Uninstall { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = uninstall::Input { namespace };
log::debug!("Uninstalling {args:?}");
Ok(uninstall::command(m, args).await)
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Domain directory is required. We store files and credentials there")]
Domain,
#[error("Could not determine the home directory. Pass --home to specify one")]
Home,
#[error("quilt_rs error: {0}")]
Quilt(quilt_rs::Error),
#[error(
r"
Please visit https://{0}/code to get your code.
Then run:
> quilt_rs login --host {0} --code YOUR_CODE"
)]
LoginRequired(Host),
#[error("Package {0} not found")]
NamespaceNotFound(Namespace),
#[error(
"Could not infer a namespace from the current directory. Pass --namespace or run inside <home>/<prefix>/<name>"
)]
NamespaceRequired,
#[error("Invalid JSON for user_meta object. Object is required")]
CommitMetaInvalid(String),
#[error("workflow id cannot be empty; omit --workflow to use the bucket's default workflow")]
WorkflowEmpty,
#[error(
"--workflow/--no-workflow apply only to a first push; pass --bucket and --origin to set the remote, or omit them (the workflow was chosen at commit time)"
)]
WorkflowRequiresBucket,
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[cfg(test)]
#[error("Test failed: {0}")]
Test(String),
#[error("Failed to write or read: {0}")]
Io(#[from] std::io::Error),
}
impl From<quilt_rs::Error> for Error {
fn from(err: quilt_rs::Error) -> Error {
Error::Quilt(err)
}
}
impl From<quilt_uri::UriError> for Error {
fn from(err: quilt_uri::UriError) -> Error {
Error::Quilt(quilt_rs::Error::Uri(err))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
use test_log::test;
use crate::cli::model::create_model_in_temp_dir;
use crate::cli::model::install_package_into_temp_dir;
#[test]
fn commit_workflow_intent_omit_maps_to_bucket_default() {
assert_eq!(
commit_workflow_intent(None, false).unwrap(),
WorkflowIntent::BucketDefault
);
}
#[test]
fn commit_workflow_intent_no_workflow_flag_opts_out() {
assert_eq!(
commit_workflow_intent(None, true).unwrap(),
WorkflowIntent::NoWorkflow
);
}
#[test]
fn commit_workflow_intent_named_id_maps_to_named() {
assert_eq!(
commit_workflow_intent(Some("x"), false).unwrap(),
WorkflowIntent::Named("x".to_string())
);
}
#[test]
fn commit_workflow_intent_blank_id_is_rejected() {
assert!(matches!(
commit_workflow_intent(Some(""), false),
Err(Error::WorkflowEmpty)
));
assert!(matches!(
commit_workflow_intent(Some(" "), false),
Err(Error::WorkflowEmpty)
));
}
#[test]
fn role_set_flag_is_parsed() {
let listing = Args::try_parse_from(["quilt", "role", "--host", "example.com"]).unwrap();
assert!(matches!(listing.command, Commands::Role { set: None, .. }));
let switching = Args::try_parse_from([
"quilt",
"role",
"--host",
"example.com",
"--set",
"ReadOnly",
])
.unwrap();
let Commands::Role { host, set } = switching.command else {
panic!("expected the role command");
};
assert_eq!(host.to_string(), "example.com");
assert_eq!(set.as_deref(), Some("ReadOnly"));
}
#[test]
fn verbose_flag_is_global() {
let before_subcommand = Args::try_parse_from(["quilt", "--verbose", "list"]).unwrap();
assert!(before_subcommand.verbose);
let after_subcommand = Args::try_parse_from(["quilt", "list", "--verbose"]).unwrap();
assert!(after_subcommand.verbose);
let default = Args::try_parse_from(["quilt", "list"]).unwrap();
assert!(!default.verbose);
}
#[test]
fn json_flag_is_available_on_read_commands() {
let list = Args::try_parse_from(["quilt", "list", "--json"]).unwrap();
assert!(matches!(list.command, Commands::List { json: true }));
let status =
Args::try_parse_from(["quilt", "status", "-n", "demo/sales", "--json"]).unwrap();
assert!(matches!(
status.command,
Commands::Status { json: true, .. }
));
let default = Args::try_parse_from(["quilt", "list"]).unwrap();
assert!(matches!(default.command, Commands::List { json: false }));
}
#[test]
fn package_namespace_flag_is_optional() {
let inferred = Args::try_parse_from(["quilt", "status"]).unwrap();
assert!(matches!(
inferred.command,
Commands::Status {
pkg: PackageRef { namespace: None },
json: false
}
));
let explicit =
Args::try_parse_from(["quilt", "status", "--namespace", "demo/sales"]).unwrap();
assert!(matches!(
explicit.command,
Commands::Status {
pkg: PackageRef { namespace: Some(namespace) },
json: false
} if namespace == "demo/sales"
));
}
#[test]
fn namespace_from_working_dir_uses_package_components() -> Result<(), Error> {
let home = Path::new("/home/user/QuiltSync");
let current_dir = Path::new("/home/user/QuiltSync/demo/sales/src");
assert_eq!(
namespace_from_working_dir(home, current_dir)?,
Namespace::from(("demo", "sales"))
);
Ok(())
}
#[test]
fn namespace_from_working_dir_requires_a_package_path() {
let home = Path::new("/home/user/QuiltSync");
assert!(matches!(
namespace_from_working_dir(home, Path::new("/home/user/QuiltSync")),
Err(Error::NamespaceRequired)
));
assert!(matches!(
namespace_from_working_dir(home, Path::new("/home/user/other/demo/sales")),
Err(Error::NamespaceRequired)
));
}
#[test]
fn test_parse_optional_namespace() -> Result<(), Error> {
assert!(parse_optional_namespace(None)?.is_none());
let ns = parse_optional_namespace(Some("foo/bar".to_string()))?.unwrap();
assert_eq!(ns.to_string(), "foo/bar");
let err = parse_optional_namespace(Some("invalid".to_string())).unwrap_err();
assert!(matches!(err, Error::Quilt(_)));
Ok(())
}
#[test]
fn test_get_domain_dir() -> Result<(), Error> {
let test_dir = PathBuf::from("/test/path");
assert_eq!(get_domain_dir(Some(test_dir.clone()))?, test_dir);
if let Some(local_dir) = dirs::data_local_dir() {
let expected = local_dir.join(DOMAIN_DIR_NAMESPACE);
assert_eq!(get_domain_dir(None)?, expected);
} else {
assert!(matches!(get_domain_dir(None), Err(Error::Domain)));
}
Ok(())
}
#[test]
fn test_get_default_home_dir() -> Result<(), Error> {
let user_home = dirs::home_dir().ok_or(Error::Home)?;
assert_eq!(
get_default_home_dir()?,
user_home.join(quilt_rs::DEFAULT_HOME_DIR_NAME)
);
Ok(())
}
#[test(tokio::test)]
async fn test_list_uses_default_home_without_flag() -> Result<(), Error> {
let domain_temp_dir = tempfile::tempdir()?;
let list_args = Args {
home: None,
domain: Some(domain_temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::List { json: false },
};
let mut output = Vec::new();
let result = init(list_args).await?;
print(result, &mut output, &mut Vec::new())?;
assert_eq!(
String::from_utf8(output).unwrap(),
"No installed packages\n"
);
let stored_home = quilt_rs::LocalDomain::new(domain_temp_dir.path())
.get_home()
.await?;
assert_eq!(stored_home.as_ref(), &get_default_home_dir()?);
Ok(())
}
#[test(tokio::test)]
async fn test_missing_home_lineage_is_repaired_without_flag() -> Result<(), Error> {
let (model, domain_temp_dir) = Model::from_temp_dir()?;
let paths = quilt_rs::paths::DomainPaths::new(domain_temp_dir.path().to_path_buf());
std::fs::create_dir_all(paths.dot_quilt_dir())?;
std::fs::write(paths.lineage(), br#"{"packages":{},"home":""}"#)?;
initialize_home(&model, None).await?;
assert_eq!(model.get_home().await?.as_ref(), &get_default_home_dir()?);
Ok(())
}
#[test(tokio::test)]
async fn test_existing_home_is_preserved_without_flag() -> Result<(), Error> {
let (model, _domain_temp_dir) = Model::from_temp_dir()?;
let home_temp_dir = tempfile::tempdir()?;
model.set_home(home_temp_dir.path()).await?;
initialize_home(&model, None).await?;
assert_eq!(
model.get_home().await?.as_ref(),
&home_temp_dir.path().to_path_buf()
);
Ok(())
}
#[test(tokio::test)]
async fn live_install() -> Result<(), Error> {
use crate::cli::fixtures::packages::workflow_null as pkg;
let domain_temp_dir = tempfile::tempdir()?;
let domain = Some(domain_temp_dir.path().to_path_buf());
let working_temp_dir = tempfile::tempdir()?;
let home = Some(working_temp_dir.path().to_path_buf());
let install_args = Args {
home,
domain,
verbose: false,
command: Commands::Install {
namespace: Some(Namespace::from(pkg::NAMESPACE).to_string()),
uri: pkg::URI.to_string(),
path: None,
},
};
let mut output = Vec::new();
let result = init(install_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"Installed package \"{}\"\nNo paths installed\n",
pkg::NAMESPACE_STR,
)
);
Ok(())
}
#[test(tokio::test)]
async fn live_commit_valid() -> Result<(), Error> {
use crate::cli::fixtures::packages::workflow_null as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let commit_args = Args {
home: Some(temp_dir.path().to_path_buf()),
domain: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Commit {
message: pkg::MESSAGE.to_string(),
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
user_meta: None,
workflow: None,
no_workflow: true,
},
};
let mut output = Vec::new();
let result = init(commit_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
"New commit \"095017e53f4c8e0a07c82e562d088aa0e0f7a9ecaf2dce74a7607fac9085e98f\" created\n".to_string()
);
Ok(())
}
#[test(tokio::test)]
async fn live_commit_invalid() -> Result<(), Error> {
use crate::cli::fixtures::packages::workflow_null as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let commit_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Commit {
message: "Any message".to_string(),
pkg: PackageRef {
namespace: Some("in/valid".to_string()),
},
user_meta: None,
workflow: None,
no_workflow: true,
},
};
let mut output = Vec::new();
let result = init(commit_args).await?;
print(result, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "Package in/valid not found\n".to_string());
Ok(())
}
#[test(tokio::test)]
async fn test_push_workflow_without_bucket_errors() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let push_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Push {
pkg: PackageRef {
namespace: Some("foo/bar".to_string()),
},
bucket: None,
origin: None,
workflow: Some("x".to_string()),
no_workflow: false,
},
};
assert!(matches!(
init(push_args).await,
Err(Error::WorkflowRequiresBucket)
));
Ok(())
}
#[test(tokio::test)]
async fn test_push_no_workflow_without_bucket_errors() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let push_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Push {
pkg: PackageRef {
namespace: Some("foo/bar".to_string()),
},
bucket: None,
origin: None,
workflow: None,
no_workflow: true,
},
};
assert!(matches!(
init(push_args).await,
Err(Error::WorkflowRequiresBucket)
));
Ok(())
}
#[test(tokio::test)]
async fn test_push_workflow_with_bucket_is_accepted() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let push_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Push {
pkg: PackageRef {
namespace: Some("foo/bar".to_string()),
},
bucket: Some("some-bucket".to_string()),
origin: Some(Host::from_str("open.quiltdata.com").unwrap()),
workflow: Some("x".to_string()),
no_workflow: false,
},
};
let mut output = Vec::new();
let result = init(push_args).await?;
print(result, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "Package foo/bar not found\n");
Ok(())
}
#[test(tokio::test)]
async fn live_pull_valid() -> Result<(), Error> {
use crate::cli::fixtures::packages::outdated as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"Revision \"{}\" pulled\nLatest revision: Today's Date: 2024-07-29 11:53:53\n",
pkg::LATEST_TOP_HASH
)
);
Ok(())
}
#[test(tokio::test)]
async fn live_pull_reports_what_the_revision_brought() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::R1_URI).await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
concat!(
"Revision \"{}\" pulled\n",
"7 files new, not downloaded:\n",
" add/deeply/nested/directory/with/a/very-long-name/summary-of-everything.parquet\n",
" add/five.txt\n",
" add/four.txt\n",
" add/one.txt\n",
" add/six.txt\n",
" add/three.txt\n",
" add/two.txt\n",
"Latest revision: {}\n",
),
pkg::R3_TOP_HASH,
pkg::R3_MESSAGE,
)
);
let groups = output_str
.split("Latest revision:")
.next()
.expect("split always yields one part");
for silent in ["modify.txt", "keep.txt", "remove.txt"] {
assert!(
!groups.contains(silent),
"{silent} changed on the remote but nothing moved here, so no group may name it"
);
}
Ok(())
}
#[test(tokio::test)]
async fn live_pull_reports_updates_and_removals_for_tracked_paths() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
use crate::cli::model::install_paths_into_temp_dir;
let tracked = ["keep.txt", "modify.txt", "remove.txt"]
.iter()
.map(std::path::PathBuf::from)
.collect();
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
concat!(
"Revision \"{}\" pulled\n",
"7 files new, not downloaded:\n",
" add/deeply/nested/directory/with/a/very-long-name/summary-of-everything.parquet\n",
" add/five.txt\n",
" add/four.txt\n",
" add/one.txt\n",
" add/six.txt\n",
" add/three.txt\n",
" add/two.txt\n",
"2 files updated:\n",
" keep.txt\n",
" modify.txt\n",
"1 file removed:\n",
" remove.txt\n",
"Latest revision: {}\n",
),
pkg::R3_TOP_HASH,
pkg::R3_MESSAGE,
)
);
Ok(())
}
fn mapped_reader_child(path: &str, len: usize) {
use std::os::unix::io::AsRawFd as _;
let file = std::fs::File::open(path).expect("map target");
let addr = unsafe {
libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ,
libc::MAP_PRIVATE,
file.as_raw_fd(),
0,
)
};
assert!(!std::ptr::eq(addr, libc::MAP_FAILED), "mmap failed");
println!("MAPPED");
let mut line = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line).ok();
let byte = unsafe { std::ptr::read_volatile((addr as *const u8).add(len - 1)) };
println!("READ {byte}");
}
#[test(tokio::test)]
#[allow(
clippy::zombie_processes,
reason = "the child is killed and reaped on every path out, including a failed pull and each failed assertion; the analysis cannot follow it through the branches"
)]
async fn live_pull_leaves_a_memory_mapped_file_readable() -> Result<(), Error> {
use crate::cli::fixtures::packages::shrinking as pkg;
use crate::cli::model::install_paths_into_temp_dir;
const CHILD_ENV: &str = "QUILT_MMAP_TEST_FILE";
if let Ok(path) = std::env::var(CHILD_ENV) {
mapped_reader_child(&path, pkg::R1_LEN);
return Ok(());
}
let tracked = vec![std::path::PathBuf::from(pkg::MAPPED)];
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let root = temp_dir.path().to_path_buf();
let mapped = root.join(pkg::NAMESPACE_STR).join(pkg::MAPPED);
assert_eq!(
std::fs::metadata(&mapped).unwrap().len(),
pkg::R1_LEN as u64,
"r1 should be installed at its full size"
);
let mut kid = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"cli::tests::live_pull_leaves_a_memory_mapped_file_readable",
"--exact",
"--nocapture",
])
.env(CHILD_ENV, &mapped)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn the reader");
fn wait_for(out: &mut impl std::io::BufRead, marker: &str) -> Option<String> {
let mut line = String::new();
while {
line.clear();
std::io::BufRead::read_line(out, &mut line).unwrap_or(0) > 0
} {
if line.starts_with(marker) {
return Some(line.trim_end().to_string());
}
}
None
}
let mut out = std::io::BufReader::new(kid.stdout.take().unwrap());
let mapped_ok = wait_for(&mut out, "MAPPED").is_some();
if !mapped_ok {
kid.kill().ok();
kid.wait().ok();
panic!("the child never reported a mapping");
}
let pull = Args {
domain: Some(root.clone()),
home: Some(root.clone()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let pulled = async {
let result = init(pull).await?;
print(result, &mut output, &mut Vec::new())?;
Ok::<_, Error>(())
}
.await;
if pulled.is_err() {
kid.kill().ok();
kid.wait().ok();
}
pulled?;
let report = String::from_utf8(output).unwrap();
let landed_r2 = report.contains(pkg::R2_TOP_HASH);
let shrank = std::fs::metadata(&mapped).unwrap().len() < pkg::R1_LEN as u64;
if !(landed_r2 && shrank) {
kid.kill().ok();
kid.wait().ok();
assert!(landed_r2, "the pull should land r2, got: {report}");
assert!(shrank, "r2 should be the shorter file");
}
use std::io::Write as _;
kid.stdin.take().unwrap().write_all(b"\n").ok();
let read = wait_for(&mut out, "READ");
let status = kid.wait().expect("reader should be reapable");
assert!(
read.is_some(),
"the mapped reader never completed its read — the pull killed it: {status:?}"
);
assert!(
status.success(),
"the mapped reader did not exit cleanly: {status:?}"
);
Ok(())
}
#[test(tokio::test)]
async fn live_killed_pull_leaves_every_tracked_path_at_the_old_revision() -> Result<(), Error> {
use crate::cli::fixtures::packages::large as pkg;
use crate::cli::model::install_paths_into_temp_dir;
const CHILD_ENV: &str = "QUILT_KILLED_PULL_DOMAIN";
fn pull_args(root: &std::path::Path) -> Args {
Args {
domain: Some(root.to_path_buf()),
home: Some(root.to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
}
}
if let Ok(root) = std::env::var(CHILD_ENV) {
let _ = init(pull_args(std::path::Path::new(&root))).await;
return Ok(());
}
let tracked = pkg::PATHS
.iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let root = temp_dir.path().to_path_buf();
let working = |name: &str| root.join(pkg::NAMESPACE_STR).join(name);
let at_r1: Vec<Vec<u8>> = pkg::PATHS
.iter()
.map(|name| std::fs::read(working(name)).expect("installed at r1"))
.collect();
let mut kid = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"cli::tests::live_killed_pull_leaves_every_tracked_path_at_the_old_revision",
"--exact",
"--nocapture",
])
.env(CHILD_ENV, &root)
.spawn()
.expect("spawn the pull that gets killed");
let staging = root.join(".quilt").join("staging");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let mut staged = false;
while std::time::Instant::now() < deadline {
if std::fs::read_dir(&staging).is_ok_and(|entries| {
entries.flatten().any(|run| {
std::fs::read_dir(run.path())
.is_ok_and(|f| f.flatten().count() >= pkg::PATHS.len())
})
}) {
staged = true;
break;
}
if kid.try_wait().ok().flatten().is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
kid.kill().ok();
kid.wait().ok();
assert!(
staged,
"the whole touch set was never staged at once, so either the pull was not interrupted mid-apply or files are being swapped in one at a time"
);
for (name, before) in pkg::PATHS.iter().zip(&at_r1) {
let after = std::fs::read(working(name))
.unwrap_or_else(|err| panic!("{name} is absent after a killed pull: {err:?}"));
assert_eq!(
after.len(),
before.len(),
"{name} changed size, so it holds neither revision whole"
);
assert!(&after == before, "{name} was written before the swap began");
}
let mut output = Vec::new();
let result = init(pull_args(&root)).await?;
print(result, &mut output, &mut Vec::new())?;
let report = String::from_utf8(output).unwrap();
assert!(
report.contains(pkg::R2_TOP_HASH),
"the retry should land r2, got: {report}"
);
for (name, before) in pkg::PATHS.iter().zip(&at_r1) {
let after = std::fs::read(working(name)).expect("present after the retry");
assert!(&after != before, "{name} still holds r1 after the retry");
}
Ok(())
}
#[test(tokio::test)]
async fn live_pull_keeps_local_work_the_remote_did_not_touch() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
use crate::cli::model::install_paths_into_temp_dir;
let tracked = ["add/one.txt", "keep.txt"]
.iter()
.map(std::path::PathBuf::from)
.collect();
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R2_URI, Some(tracked)).await?;
let edited = temp_dir.path().join(pkg::NAMESPACE_STR).join("add/one.txt");
std::fs::write(&edited, "MY LOCAL EDIT\n").expect("the path was just checked out");
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
concat!(
"Revision \"{}\" pulled\n",
"1 file new, not downloaded:\n",
" add/six.txt\n",
"1 file updated:\n",
" keep.txt\n",
"Latest revision: {}\n",
),
pkg::R3_TOP_HASH,
pkg::R3_MESSAGE,
)
);
assert_eq!(
std::fs::read_to_string(&edited).unwrap(),
"MY LOCAL EDIT\n",
"the pull overwrote local work the remote had not touched"
);
Ok(())
}
#[test(tokio::test)]
async fn live_pull_blocked_by_a_conflict_reports_nothing() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
use crate::cli::model::install_paths_into_temp_dir;
let tracked = vec![std::path::PathBuf::from("keep.txt")];
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let root = temp_dir.path().join(pkg::NAMESPACE_STR).join("add");
std::fs::create_dir_all(&root).expect("the package root exists");
std::fs::write(root.join("one.txt"), "DIFFERENT CONTENT THAN THE REMOTE\n")
.expect("just created the directory");
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut out = Vec::new();
let mut errs = Vec::new();
let result = init(pull_args).await?;
print(result, &mut out, &mut errs).ok();
let out = String::from_utf8(out).unwrap();
let errs = String::from_utf8(errs).unwrap();
assert!(
errs.contains("add/one.txt"),
"the refusal must name the conflicting path: {errs}"
);
assert!(
out.is_empty(),
"a blocked pull applies nothing and must report nothing, got: {out}"
);
Ok(())
}
#[test(tokio::test)]
async fn test_pull_invalid() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some("in/valid".to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "Package in/valid not found\n");
Ok(())
}
#[test(tokio::test)]
async fn live_uninstall_valid() -> Result<(), Error> {
use crate::cli::fixtures::packages::default as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let uninstall_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Uninstall {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(uninstall_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!("Package {} successfully uninstalled\n", pkg::NAMESPACE_STR)
);
Ok(())
}
#[test(tokio::test)]
async fn test_uninstall_invalid() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let uninstall_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Uninstall {
pkg: PackageRef {
namespace: Some("in/valid".to_string()),
},
},
};
let mut output = Vec::new();
let result = init(uninstall_args).await?;
print(result, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert!(output_str.ends_with("The given package is not installed: in/valid\n"));
Ok(())
}
#[test(tokio::test)]
async fn test_list_invalid() -> Result<(), Error> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use tempfile::Builder;
let write_only = Permissions::from_mode(0o200);
let temp_dir = Builder::new().permissions(write_only).tempdir()?;
let list_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::List { json: false },
};
let err = init(list_args).await.unwrap_err();
assert!(err.to_string().contains("Permission denied"));
Ok(())
}
#[test(tokio::test)]
async fn test_list_valid() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let list_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::List { json: false },
};
let mut output = Vec::new();
let result = init(list_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "No installed packages\n");
Ok(())
}
#[test(tokio::test)]
async fn test_install_invalid() -> Result<(), Error> {
use crate::cli::fixtures::packages::invalid as pkg;
let temp_dir = tempfile::tempdir()?;
let domain = Some(temp_dir.path().to_path_buf());
let home = domain.clone();
let install_args = Args {
domain,
home,
verbose: false,
command: Commands::Install {
namespace: None,
uri: pkg::URI.to_string(),
path: None,
},
};
let mut output = Vec::new();
let result = init(install_args).await?;
print(result, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"quilt_rs error: Invalid package URI: S3 package URI must contain a fragment: {}\n",
pkg::URI
)
);
Ok(())
}
#[test(tokio::test)]
async fn live_browse_valid() -> Result<(), Error> {
use crate::cli::fixtures::get_browse_output;
use crate::cli::fixtures::packages::default as pkg;
let temp_dir = tempfile::tempdir()?;
let uri = format!("{}&path={}", pkg::URI_LATEST, pkg::README_LK_ESCAPED);
let browse_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Browse { uri },
};
let mut output = Vec::new();
let result = init(browse_args).await?;
print(result, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, format!("{}\n", get_browse_output()?));
Ok(())
}
#[test(tokio::test)]
async fn test_browse_invalid() -> Result<(), Error> {
use crate::cli::fixtures::packages::invalid as pkg;
let temp_dir = tempfile::tempdir()?;
let browse_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
command: Commands::Browse {
uri: pkg::URI.to_string(),
},
};
let mut output = Vec::new();
let result = init(browse_args).await?;
print(result, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"quilt_rs error: Invalid package URI: S3 package URI must contain a fragment: {}\n",
pkg::URI
)
);
Ok(())
}
}