Skip to content
Merged
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
59 changes: 33 additions & 26 deletions src/config/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,32 +47,7 @@ impl Config {
let mut assigned: HashSet<&str> = HashSet::new();

for group in &groups {
let kind = group.kind.label();
if group.members.len() < 2 {
errors.push(format!(
"{kind} group {:?} must list at least two packages",
group.members
));
}
let mut seen_in_group: HashSet<&str> = HashSet::new();
for member in &group.members {
if !known.contains(member.as_str()) {
errors.push(format!(
"package '{member}' in a {kind} group is not defined in package[]"
));
}
if !seen_in_group.insert(member.as_str()) {
errors.push(format!(
"package '{member}' is listed twice in the same {kind} group"
));
continue;
}
if !assigned.insert(member.as_str()) {
errors.push(format!(
"package '{member}' appears in more than one linked/fixed group"
));
}
}
check_group(group, &known, &mut assigned, &mut errors);
}

if errors.is_empty() {
Expand All @@ -83,6 +58,38 @@ impl Config {
}
}

fn check_group<'a>(
group: &'a PackageGroup,
known: &HashSet<&str>,
assigned: &mut HashSet<&'a str>,
errors: &mut Vec<String>,
) {
let kind = group.kind.label();
if group.members.len() < 2 {
errors.push(format!(
"{kind} group {:?} must list at least two packages",
group.members
));
}
let mut seen_in_group: HashSet<&str> = HashSet::new();
for member in &group.members {
if !known.contains(member.as_str()) {
errors.push(format!(
"package '{member}' in a {kind} group is not defined in package[]"
));
}
if !seen_in_group.insert(member.as_str()) {
errors.push(format!(
"package '{member}' is listed twice in the same {kind} group"
));
} else if !assigned.insert(member.as_str()) {
errors.push(format!(
"package '{member}' appears in more than one linked/fixed group"
));
}
}
}

#[cfg(test)]
mod tests {
use super::super::Config;
Expand Down
123 changes: 56 additions & 67 deletions src/config/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ fn parse_file_format(s: &str) -> FileFormat {
}
}

fn question(text: &str, indented: bool) -> String {
if indented {
format!(" {text}")
} else {
text.to_string()
}
}

fn under(dir: &str, file: &str) -> String {
if dir == "." {
file.to_string()
} else {
format!("{dir}/{file}")
}
}

fn collect_package(path_default: &str, monorepo: bool) -> PackageConfig {
let dir_name = std::env::current_dir()
.ok()
Expand All @@ -112,49 +128,21 @@ fn collect_package(path_default: &str, monorepo: bool) -> PackageConfig {
})
.unwrap_or_else(|| "project".to_string());

let name = if monorepo {
prompt(" Package name", "")
} else {
prompt("Package name", &dir_name)
};
let name_default = if monorepo { "" } else { dir_name.as_str() };
let name = prompt(&question("Package name", monorepo), name_default);

let path = prompt(if monorepo { " Path" } else { "Path" }, path_default);
let path = prompt(&question("Path", monorepo), path_default);

let format_str = prompt_format(monorepo);

let version_file_default = default_version_file(&format_str);
let version_file_path = if path == "." {
prompt(
if monorepo {
" Version file path"
} else {
"Version file path"
},
version_file_default,
)
} else {
prompt(
if monorepo {
" Version file path"
} else {
"Version file path"
},
&format!("{path}/{version_file_default}"),
)
};
let version_file_path = prompt(
&question("Version file path", monorepo),
&under(&path, default_version_file(&format_str)),
);

let changelog_default = if path == "." {
"CHANGELOG.md".to_string()
} else {
format!("{path}/CHANGELOG.md")
};
let changelog = prompt(
if monorepo {
" Changelog path"
} else {
"Changelog path"
},
&changelog_default,
&question("Changelog path", monorepo),
&under(&path, "CHANGELOG.md"),
);

PackageConfig {
Expand Down Expand Up @@ -183,45 +171,46 @@ fn collect_package(path_default: &str, monorepo: bool) -> PackageConfig {

const DEFAULT_MANIFEST_FILE: &str = ".ferrflow.manifest.json";

pub fn init(format: Option<ConfigFileFormat>, manifest: bool) -> Result<()> {
for handler in CONFIG_FORMATS {
let path = PathBuf::from(handler.filename());
if path.exists() {
Err(anyhow::anyhow!("{} already exists", handler.filename()))
.error_code(error_code::CONFIG_ALREADY_EXISTS)?;
}
fn ensure_no_config_exists() -> Result<()> {
let existing = CONFIG_FORMATS
.iter()
.map(|handler| handler.filename())
.chain([TS_CONFIG_FILENAME, JS_CONFIG_FILENAME])
.find(|filename| PathBuf::from(filename).exists());
if let Some(filename) = existing {
Err(anyhow::anyhow!("{filename} already exists"))
.error_code(error_code::CONFIG_ALREADY_EXISTS)?;
}
Ok(())
}

fn collect_packages(monorepo: bool) -> Vec<PackageConfig> {
if !monorepo {
return vec![collect_package(".", false)];
}
for filename in [TS_CONFIG_FILENAME, JS_CONFIG_FILENAME] {
let path = PathBuf::from(filename);
if path.exists() {
Err(anyhow::anyhow!("{filename} already exists"))
.error_code(error_code::CONFIG_ALREADY_EXISTS)?;
println!("Add packages (leave name empty to finish):");
let mut pkgs = Vec::new();
loop {
let pkg = collect_package("", true);
if !pkg.name.is_empty() {
pkgs.push(pkg);
} else if pkgs.is_empty() {
eprintln!("At least one package is required.");
} else {
return pkgs;
}
}
}

pub fn init(format: Option<ConfigFileFormat>, manifest: bool) -> Result<()> {
ensure_no_config_exists()?;

let fmt = format.unwrap_or_else(prompt_config_format);
let handler = format_handler(fmt);

let monorepo = prompt_bool("Is this a monorepo?", false);

let packages = if monorepo {
println!("Add packages (leave name empty to finish):");
let mut pkgs = Vec::new();
loop {
let pkg = collect_package("", true);
if pkg.name.is_empty() {
if pkgs.is_empty() {
eprintln!("At least one package is required.");
continue;
}
break;
}
pkgs.push(pkg);
}
pkgs
} else {
vec![collect_package(".", false)]
};
let packages = collect_packages(monorepo);

let mut workspace = WorkspaceConfig::default();
if manifest {
Expand Down
137 changes: 76 additions & 61 deletions src/config/loader_js.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,6 @@ process.stdout.write(JSON.stringify(cfg));"#
}

pub(crate) fn load_js_ts_config(path: &Path) -> Result<Config> {
use std::process::Command;

let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let filename = path
.file_name()
Expand All @@ -97,68 +95,85 @@ pub(crate) fn load_js_ts_config(path: &Path) -> Result<Config> {
let file_url = path_to_file_url(path)?;

let output = if ext == "ts" {
let wrapper_tempdir = tempfile::tempdir()
.with_context(|| "Failed to create temporary directory for TS loader")
.error_code(error_code::CONFIG_WRITE_LOADER)?;
let wrapper_path = wrapper_tempdir.path().join("loader.mjs");
let tsx_available = Command::new("tsx").arg("--version").output().is_ok();
let runtime = if tsx_available { "tsx" } else { "npx tsx" };

let script = loader_body(&file_url, runtime);
std::fs::write(&wrapper_path, &script)
.with_context(|| "Failed to write temporary loader file")
.error_code(error_code::CONFIG_WRITE_LOADER)?;

let result = Command::new("tsx")
.arg(&wrapper_path)
.current_dir(wrapper_tempdir.path())
.output()
.or_else(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Command::new("npx")
.args(["tsx"])
.arg(&wrapper_path)
.current_dir(wrapper_tempdir.path())
.output()
} else {
Err(e)
}
})
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"{filename} requires tsx but neither 'tsx' nor 'npx tsx' was found.\n\
Install with: npm install -g tsx"
)
} else {
anyhow::anyhow!("Failed to execute tsx: {e}")
}
})
.error_code(error_code::CONFIG_EVAL_TS);

drop(wrapper_tempdir);
result?
run_ts_loader(&file_url, filename)?
} else {
let script = loader_body(&file_url, "node");

let parent = path.parent().unwrap_or(Path::new("."));
Command::new("node")
.args(["--input-type=module", "-e", &script])
.current_dir(parent)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"{filename} requires Node.js but 'node' was not found in PATH.\n\
Install Node.js from https://nodejs.org/"
)
} else {
anyhow::anyhow!("Failed to execute node: {e}")
}
})
.error_code(error_code::CONFIG_EVAL_NODE)?
run_node_loader(path, &file_url, filename)?
};

parse_loader_output(output, path, filename)
}

fn run_ts_loader(file_url: &str, filename: &str) -> Result<std::process::Output> {
use std::process::Command;

let wrapper_tempdir = tempfile::tempdir()
.with_context(|| "Failed to create temporary directory for TS loader")
.error_code(error_code::CONFIG_WRITE_LOADER)?;
let wrapper_path = wrapper_tempdir.path().join("loader.mjs");
let tsx_available = Command::new("tsx").arg("--version").output().is_ok();
let runtime = if tsx_available { "tsx" } else { "npx tsx" };

let script = loader_body(file_url, runtime);
std::fs::write(&wrapper_path, &script)
.with_context(|| "Failed to write temporary loader file")
.error_code(error_code::CONFIG_WRITE_LOADER)?;

let result = Command::new("tsx")
.arg(&wrapper_path)
.current_dir(wrapper_tempdir.path())
.output()
.or_else(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Command::new("npx")
.args(["tsx"])
.arg(&wrapper_path)
.current_dir(wrapper_tempdir.path())
.output()
} else {
Err(e)
}
})
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"{filename} requires tsx but neither 'tsx' nor 'npx tsx' was found.\n\
Install with: npm install -g tsx"
)
} else {
anyhow::anyhow!("Failed to execute tsx: {e}")
}
})
.error_code(error_code::CONFIG_EVAL_TS);

drop(wrapper_tempdir);
result
}

fn run_node_loader(path: &Path, file_url: &str, filename: &str) -> Result<std::process::Output> {
let script = loader_body(file_url, "node");
let parent = path.parent().unwrap_or(Path::new("."));
std::process::Command::new("node")
.args(["--input-type=module", "-e", &script])
.current_dir(parent)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"{filename} requires Node.js but 'node' was not found in PATH.\n\
Install Node.js from https://nodejs.org/"
)
} else {
anyhow::anyhow!("Failed to execute node: {e}")
}
})
.error_code(error_code::CONFIG_EVAL_NODE)
}

fn parse_loader_output(
output: std::process::Output,
path: &Path,
filename: &str,
) -> Result<Config> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!("Failed to evaluate {filename}:\n{stderr}"))
Expand Down
Loading
Loading