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(())
}
#[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(())
}
}