use std::collections::BTreeMap;
use std::path::Path;
use rahti_native::{NativeConfig, NativeError, check_identifier};
use crate::args::Init;
use crate::project::Project;
use crate::{icons, schema, shell};
#[derive(PartialEq, Eq, Clone, Copy)]
enum Outcome {
Created,
Updated,
Unchanged,
Kept,
}
pub fn run(project: &Project, args: &Init) -> Result<(), NativeError> {
let existing = read_existing(project)?;
let mut config = build_config(project, args, existing.as_ref())?;
config.validate()?;
let mut hashes = config.scaffold.clone();
let files = shell::files(project, &config);
let mut outcomes: Vec<(String, Outcome)> = Vec::new();
for file in &files {
let outcome = write_file(project, file, &mut hashes, args.force)?;
outcomes.push((file.path.clone(), outcome));
}
outcomes.extend(write_icons(project)?);
let schema_path = project.native_schema();
let schema = schema::json();
outcomes.push((
relative(project, &schema_path),
put(&schema_path, &schema, true)?,
));
let config_path = project.native_config();
config.scaffold = hashes;
let config_outcome = put(&config_path, &config.to_json(), true)?;
outcomes.push((relative(project, &config_path), config_outcome));
report(&outcomes, &config, project);
Ok(())
}
fn build_config(
project: &Project,
args: &Init,
existing: Option<&NativeConfig>,
) -> Result<NativeConfig, NativeError> {
let mut config = match existing {
Some(config) => config.clone(),
None => {
let identifier = args.identifier.as_deref().ok_or_else(|| {
NativeError::new(
"config",
"this project has no native configuration yet, so `init` needs an \
identifier.\n \
It is reverse-DNS, it is the Android package name, and changing it later \
makes a different application:\n \
cargo rahti native init --identifier com.example.myapp --windows --android",
)
})?;
check_identifier(identifier).map_err(|m| NativeError::new("config", m))?;
let targets: Vec<&str> = if args.targets.is_empty() {
rahti_native::TARGETS.to_vec()
} else {
args.targets.clone()
};
let name = args
.product_name
.clone()
.unwrap_or_else(|| title_case(&project.package));
let mut config = NativeConfig::new(
&name,
identifier,
args.version.as_deref().unwrap_or(&project.version),
&targets,
);
config.auth.cookie_name = project.cookie_name();
config
}
};
if let Some(identifier) = &args.identifier {
check_identifier(identifier).map_err(|m| NativeError::new("config", m))?;
config.identifier = identifier.clone();
}
if let Some(name) = &args.product_name {
config.product_name = name.clone();
config.window.title = name.clone();
}
if let Some(version) = &args.version {
config.version = version.clone();
}
if let Some(local) = &args.local {
config.local = Some(local.replace('\\', "/"));
}
for target in &args.targets {
if !config.targets.iter().any(|t| t == target) {
config.targets.push((*target).to_string());
}
}
Ok(config)
}
fn read_existing(project: &Project) -> Result<Option<NativeConfig>, NativeError> {
let path = project.native_config();
if !path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(&path).map_err(|e| NativeError::io("config", &path, e))?;
if raw_csp(&text).is_some_and(|csp| rahti_native::superseded_csp(&csp)) {
println!(" updated `security.csp` — the previous default could not run PulsePoint");
}
NativeConfig::parse(&text).map(Some).map_err(|mut e| {
e.path = Some(path);
e
})
}
fn raw_csp(text: &str) -> Option<String> {
let raw: serde_json::Value = serde_json::from_str(text).ok()?;
raw.get("security")?
.get("csp")?
.as_str()
.map(str::to_string)
}
fn write_file(
project: &Project,
file: &shell::File,
hashes: &mut BTreeMap<String, String>,
force: bool,
) -> Result<Outcome, NativeError> {
let path = project.root.join(&file.path);
let recorded = hashes.get(&file.path).cloned();
let outcome = if path.is_file() {
let current = std::fs::read_to_string(&path)
.map_err(|e| NativeError::io("init", &path, e))?
.replace("\r\n", "\n");
let current_hash = hash(¤t);
let edited = recorded.as_deref() != Some(current_hash.as_str());
if edited && !force {
Outcome::Kept
} else if current_hash == hash(&file.contents) {
Outcome::Unchanged
} else {
put(&path, &file.contents, true)?;
Outcome::Updated
}
} else {
put(&path, &file.contents, true)?;
Outcome::Created
};
if outcome != Outcome::Kept {
hashes.insert(file.path.clone(), hash(&file.contents));
}
Ok(outcome)
}
fn write_icons(project: &Project) -> Result<Vec<(String, Outcome)>, NativeError> {
let dir = project.native_dir().join("icons");
let mut outcomes = Vec::new();
for icon in icons::all() {
let path = dir.join(icon.path);
outcomes.push((relative(project, &path), put_bytes(&path, icon.bytes)?));
}
Ok(outcomes)
}
fn put(path: &Path, contents: &str, overwrite: bool) -> Result<Outcome, NativeError> {
if path.is_file() {
let current = std::fs::read_to_string(path)
.map_err(|e| NativeError::io("init", path, e))?
.replace("\r\n", "\n");
if current == contents {
return Ok(Outcome::Unchanged);
}
if !overwrite {
return Ok(Outcome::Kept);
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| NativeError::io("init", parent, e))?;
}
let created = !path.exists();
std::fs::write(path, contents).map_err(|e| NativeError::io("init", path, e))?;
Ok(if created {
Outcome::Created
} else {
Outcome::Updated
})
}
fn put_bytes(path: &Path, contents: &[u8]) -> Result<Outcome, NativeError> {
if path.is_file() {
return Ok(Outcome::Unchanged);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| NativeError::io("init", parent, e))?;
}
std::fs::write(path, contents).map_err(|e| NativeError::io("init", path, e))?;
Ok(Outcome::Created)
}
fn hash(contents: &str) -> String {
let mut value = 0xcbf2_9ce4_8422_2325u64;
for byte in contents.replace("\r\n", "\n").as_bytes() {
value ^= *byte as u64;
value = value.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{value:016x}")
}
fn relative(project: &Project, path: &Path) -> String {
path.strip_prefix(&project.root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
fn title_case(package: &str) -> String {
package
.split(['-', '_'])
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn report(outcomes: &[(String, Outcome)], config: &NativeConfig, project: &Project) {
let mut kept = Vec::new();
let mut changed = 0;
for (path, outcome) in outcomes {
match outcome {
Outcome::Created => {
println!(" created {path}");
changed += 1;
}
Outcome::Updated => {
println!(" updated {path}");
changed += 1;
}
Outcome::Kept => kept.push(path),
Outcome::Unchanged => {}
}
}
if changed == 0 && kept.is_empty() {
println!(" nothing to do — the native shell is current.");
}
if !kept.is_empty() {
println!();
println!(" These were edited since they were generated, and were left alone:");
for path in &kept {
println!(" {path}");
}
println!(" Take them back with `cargo rahti native init --force`, which does not");
println!(" keep what it replaces.");
}
println!();
println!(
" {} {} — targets: {}",
config.product_name,
config.identifier,
config.targets.join(", ")
);
println!();
if !project.has_shared_startup() {
println!(" This project's startup is still in src/main.rs, where the native shell");
println!(" cannot reach it. Run `cargo rahti native doctor` for what to do.");
println!();
}
println!(" Next:");
println!(" cargo rahti native doctor");
for target in &config.targets {
println!(" cargo rahti native dev --target {target}");
}
}