8000 feat: Headless CLI use by dhnaranjo · Pull Request #649 · nix-community/nix-init · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ pub enum RustVendor {
ImportCargoLock,
}

impl BuildType {
pub fn cli_flag(&self) -> Option<&'static str> {
match self {
BuildType::BuildGoModule => Some("go-mod"),
BuildType::BuildPythonPackage {
application: true, ..
} => Some("python-app"),
BuildType::BuildPythonPackage {
application: false, ..
} => Some("python-pkg"),
BuildType::BuildRustPackage { .. } => Some("rust-pkg"),
BuildType::MkDerivation { rust: None } => Some("drv"),
BuildType::MkDerivation { rust: Some(_) } => None,
}
}

pub fn from_cli_flag(flag: &str, choices: &[BuildType]) -> Option<BuildType> {
choices.iter().find(|c| c.cli_flag() == Some(flag)).copied()
}
}

impl Display for BuildType {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Expand Down
89 changes: 89 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ pub struct Opts {
#[arg(short, long)]
pub url: Option<String>,

/// Specify the git revision, tag, or commit hash
#[arg(short, long)]
pub rev: Option<String>,

/// Specify the package version
#[arg(long = "version-name")]
pub version: Option<String>,

/// Specify the package name
#[arg(short = 'p', long)]
pub pname: Option<String>,

/// Specify the build type
///
/// Options: {n} go-mod (buildGoModule) {n} python-app (buildPythonApplication) {n} python-pkg (buildPythonPackage) {n} rust-pkg (buildRustPackage) {n} drv (stdenv.mkDerivation)
#[arg(short, long)]
pub build: Option<String>,

/// Path to nixpkgs (in nix)
///
/// Examples:
Expand All @@ -32,4 +50,75 @@ pub struct Opts {
/// Specify the config file
#[arg(short, long)]
pub config: Option<PathBuf>,

/// Run in headless mode - accept defaults for all prompts
///
/// URL must be provided via --url flag in headless mode.
/// All other options will use sensible defaults if not specified.
#[arg(long)]
pub headless: bool,

/// Fetch git submodules (defaults to false in headless mode, prompts in interactive mode)
#[arg(long)]
pub fetch_submodules: bool,

/// Allow overwriting existing output files in headless mode
#[arg(short, long)]
pub force: bool,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_headless_with_url() {
let opts = Opts::try_parse_from(&[
"nix-init",
"--headless",
"--url",
"https://github.com/owner/repo",
])
.unwrap();

assert!(opts.headless);
assert_eq!(opts.url.unwrap(), "https://github.com/owner/repo");
}

#[test]
fn parse_all_headless_flags() {
let opts = Opts::try_parse_from(&[
"nix-init",
"--headless",
"--url",
"https://example.com",
"--rev",
"v1.0.0",
"--version-name",
"1.0.0",
"--pname",
"test-pkg",
"--build",
"rust-pkg",
"--fetch-submodules",
"--force",
])
.unwrap();

assert!(opts.headless);
assert_eq!(opts.rev.unwrap(), "v1.0.0");
assert_eq!(opts.version.unwrap(), "1.0.0");
assert_eq!(opts.pname.unwrap(), "test-pkg");
assert_eq!(opts.build.unwrap(), "rust-pkg");
assert!(opts.fetch_submodules);
assert!(opts.force);
}

#[test]
fn headless_defaults_to_false() {
let opts = Opts::try_parse_from(&["nix-init"]).unwrap();
assert!(!opts.headless);
assert!(!opts.fetch_submodules);
assert!(!opts.force);
}
}
156 changes: 116 additions & 40 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,33 @@ async fn run() -> Result<()> {
editor.set_completion_type(CompletionType::Fuzzy);
editor.set_max_history_size(0)?;

macro_rules! flag_or_prompt {
($opts:expr, $flag:expr, $default:expr, $prompt_block:expr) => {
match $flag {
Some(val) => val,
None => {
if $opts.headless {
$default
} else {
$prompt_block
}
}
}
};
}

let mut out = String::new();
writeln!(out, "{{\n lib,")?;

let url = match opts.url {
Some(url) => url,
None => {
let url = flag_or_prompt!(
opts,
opts.url,
{ anyhow::bail!("--url is required when using --headless mode") },
{
editor.set_helper(Some(Prompter::NonEmpty));
editor.readline(&prompt("Enter url"))?
}
};
);

let mut fetcher =
serde_json::from_slice(&Command::new(NURL).arg(&url).arg("-p").get_stdout().await?)
Expand All @@ -131,27 +148,29 @@ async fn run() -> Result<()> {
licenses.insert(license, 1.0);
}

let rev_msg = prompt(format_args!(
"Enter tag or revision (defaults to {})",
revisions.latest
));
let default_rev = revisions.latest.clone();
editor.set_helper(Some(Prompter::Revision(revisions)));

let rev = editor.readline(&rev_msg)?;
let rev = flag_or_prompt!(opts, opts.rev, default_rev.clone(), {
let rev_msg = prompt(format_args!(
"Enter tag or revision (defaults to {})",
default_rev
));
let rev = editor.readline(&rev_msg)?;
if rev.is_empty() {
default_rev
} else {
rev
}
});

let Some(Prompter::Revision(revisions)) = editor.helper_mut() else {
unreachable!();
};
let rev = if rev.is_empty() {
revisions.latest.clone()
} else {
rev
};

let version = match revisions.versions.remove(&rev) {
Some(version) => Some(version),
None => fetcher.get_version(&cl, &rev).await,
} ;
};
let version = match version {
Some(Version::Latest | Version::Tag) => get_version_number(&rev).into(),
Some(Version::Pypi {
Expand All @@ -170,12 +189,20 @@ async fn run() -> Result<()> {
None => get_version(&rev).into(),
};

if fetcher.has_submodules(&cl, &rev).await && !ask(&mut editor, "Fetch submodules")? {
cmd.arg("-S");
if fetcher.has_submodules(&cl, &rev).await {
if !opts.fetch_submodules {
if opts.headless {
cmd.arg("-S");
} else if !ask(&mut editor, "Fetch submodules")? {
cmd.arg("-S");
}
}
}

editor.set_helper(Some(Prompter::NonEmpty));
let version = editor.readline_with_initial(&prompt("Enter version"), (&version, ""))?;
let version = flag_or_prompt!(opts, opts.version, version.clone(), {
editor.readline_with_initial(&prompt("Enter version"), (&version, ""))?
});

(
Some(pname),
Expand All @@ -192,18 +219,36 @@ async fn run() -> Result<()> {
.map(|pname| pname.strip_suffix(".git").unwrap_or(pname).into())
});

editor.set_helper(Some(Prompter::NonEmpty));
let rev = editor.readline(&prompt("Enter tag or revision"))?;
let rev = flag_or_prompt!(opts, opts.rev, String::new(), {
editor.set_helper(Some(Prompter::NonEmpty));
editor.readline(&prompt("Enter tag or revision"))?
});
let version = get_version(&rev);
let version = editor.readline_with_initial(&prompt("Enter version"), (version, ""))?;
let version = flag_or_prompt!(opts, opts.version, version.to_string(), {
editor.readline_with_initial(&prompt("Enter version"), (version, ""))?
});
(pname, rev, version, "".into(), None, Default::default())
};

let pname = if let Some(pname) = pname {
editor.readline_with_initial(&prompt("Enter pname"), (&pname.to_kebab_case(), ""))?
} else {
editor.readline(&prompt("Enter pname"))?
};
let pname = flag_or_prompt!(
opts,
opts.pname,
{
if let Some(ref detected_pname) = pname {
detected_pname.to_kebab_case()
} else {
anyhow::bail!("--pname is required in headless mode when package name cannot be auto-detected");
}
},
{
if let Some(pname) = pname {
editor
.readline_with_initial(&prompt("Enter pname"), (&pname.to_kebab_case(), ""))?
} else {
editor.readline(&prompt("Enter pname"))?
}
}
);

let nixpkgs = opts
.nixpkgs
Expand Down Expand Up @@ -390,19 +435,37 @@ async fn run() -> Result<()> {

choices.push(BuildType::MkDerivation { rust: None });

editor.set_helper(Some(Prompter::Build(choices)));
let choice = editor.readline(&prompt("How should this package be built?"))?;
let Some(Prompter::Build(choices)) = editor.helper_mut() else {
unreachable!();
let choice = if let Some(build_str) = opts.build.as_deref() {
BuildType::from_cli_flag(build_str, &choices).with_context(|| {
format!(
"invalid build type '{}'\nAvailable options for this package:\n{}",
build_str,
choices
.iter()
.filter_map(|c| c.cli_flag().map(|flag| format!(" - {} ({})", flag, c)))
.collect::<Vec<_>>()
.join("\n")
)
})?
} else if opts.headless {
choices[0]
} else {
editor.set_helper(Some(Prompter::Build(choices)));
let choice = editor.readline(&prompt("How should this package be built?"))?;
let Some(Prompter::Build(choices)) = editor.helper_mut() else {
unreachable!();
};
*choice
.parse()
.ok()
.and_then(|i: usize| choices.get(i))
.unwrap_or_else(|| &choices[0])
};
let choice = *choice
.parse()
.ok()
.and_then(|i: usize| choices.get(i))
.unwrap_or_else(|| &choices[0]);

let output = if let Some(output) = opts.output {
output
} else if opts.headless {
PathBuf::from(".")
} else {
editor.set_helper(Some(Prompter::Path(FilenameCompleter::new())));

Expand Down Expand Up @@ -441,13 +504,26 @@ async fn run() -> Result<()> {
let (out_dir, out_path) = if let Ok(metadata) = metadata(&output) {
if metadata.is_dir() {
let out_path = output.join("default.nix");
if out_path.exists() && ask_overwrite(&mut editor, &out_path)? {
return Ok(());
if out_path.exists() {
if opts.headless && !opts.force {
anyhow::bail!(
"Output file {} already exists (refusing to overwrite in headless mode). Use --force to overwrite.",
out_path.display()
);
} else if !opts.headless && ask_overwrite(&mut editor, &out_path)? {
return Ok(());
}
}
(Some(output.as_path()), out_path)
} else if ask_overwrite(&mut editor, &output)? {
return Ok(());
} else {
if opts.headless && !opts.force {
anyhow::bail!(
"Output file {} already exists (refusing to overwrite in headless mode). Use --force to overwrite.",
output.display()
);
} else if !opts.headless && ask_overwrite(&mut editor, &output)? {
return Ok(());
}
(output.parent(), output.clone())
}
} else if <[u8] as ByteSlice>::from_path(&output)
Expand Down
0