hpkg 0.0.3

A native Rust crate to parse Haiku's binary package and repo formats
Documentation
/*
 * Copyright, 2017-2023, Alexander von Gluck IV. All rights reserved.
 * Released under the terms of the MIT license.
 *
 * vim: set noai noet ts=4 sw=4:
 *
 * Authors:
 *   Alexander von Gluck IV <kallisti5@unixzen.com>
 */

use std::error::Error;
use std::ffi::OsStr;
use std::fmt::{Display,Formatter};
use std::fs::{read_to_string};
use std::path::{Path,PathBuf};
use std::mem;

pub struct Dependencies {
	build: Vec<String>,
	run: Vec<String>,
	build_pre: Vec<String>,
}

pub struct Recipe {
	pub name: String,
	pub version: String,
	pub revision: String,
	pub path: String,
	pub category: String,
	pub provide: Vec<String>,
	pub depend: Dependencies,
}

fn field_from_recipe(path: PathBuf, field: String) -> Result<String, Box<dyn Error>> {

	let mut captured_quote_count = 0;
	let mut captured: Vec<String> = Vec::new();

	// I call it the spaghetti parser and it's beautiful and slow
	for line in read_to_string(&path)?.lines() {
		if line.starts_with("#") {
			continue;
		}
		if captured_quote_count > 1 {
			return Ok(captured.join("\n"));
		}
		if line.starts_with(field.as_str()) {
			let fields: Vec<&str> = line.split("=").collect();
			if fields.len() >= 2 {
				for f in fields.iter() {
					captured_quote_count += f.matches("\"").count();
				}
				if captured_quote_count == 0 {
					// something simple and unwrapped
					// TODO: PROVIDES= asdf = asdf
					return Ok(fields[1].to_string());
				}
				// TODO: capture fields 2+ in buffer
				// PROVIDES="thing = thing"
				let buffer = fields[1].replace("\"", "").replace("\t", "");
				if buffer == "" {
					// nothing, don't store it
					continue;
				}
				captured.push(buffer);
				if captured_quote_count > 1 {
					// We got what we needed. Lets bail
					return Ok(captured.join("\n"));
				}
			}
			continue
		}
		// If we already captured one quote, we need to collect more lines until the next one
		if captured_quote_count > 0 {
			captured_quote_count += line.matches("\"").count();
			let buffer = line.replace("\"", "").replace("\t", "");
			if buffer == "" {
				// nothing, don't store it
				continue;
			}
			captured.push(buffer);
		}
	}
	// Didn't find it..
	return Err(From::from(format!("Unknown field {} in recipe file", field)));
}

/// Recipe represents information on a loaded Recipe file.
impl Recipe {
	/// Create a new empty Recipe definition
	pub fn new() -> Recipe {
		Recipe {
			name: String::new(),
			version: String::new(),
			revision: String::new(),
			path: String::new(),
			category: String::new(),
			provide: Vec::new(),
			depend: Dependencies {
				run: Vec::new(),
				build: Vec::new(),
				build_pre: Vec::new(),
			},
		}
	}
	/// Create a new Recipe definition from a provided .recipe file
	pub fn new_from_file<P: AsRef<Path>>(path: P, category: String) -> Result<Recipe, Box<dyn Error>> {
		let recipe_name_parts: Vec<&str> = path.as_ref().file_stem()
			.and_then(OsStr::to_str).unwrap()
			.split("-").collect();
		if recipe_name_parts.len() != 2 {
			return Err(From::from(format!("Unknown recipe name")));
		}

		// Required fields can't fail
		let revision = field_from_recipe(path.as_ref().to_path_buf(), "REVISION".to_string())?;
		let provide = field_from_recipe(path.as_ref().to_path_buf(), "PROVIDES".to_string())?;

		// Optional fields can fail.. so we can't error on them
		let dep_run = field_from_recipe(path.as_ref().to_path_buf(), "REQUIRES".to_string()).unwrap_or(String::new());
		let dep_build = field_from_recipe(path.as_ref().to_path_buf(), "BUILD_REQUIRES".to_string()).unwrap_or(String::new());
		let dep_build_pre = field_from_recipe(path.as_ref().to_path_buf(), "BUILD_PREREQUIRES".to_string()).unwrap_or(String::new());

		Ok(Recipe {
			name: recipe_name_parts[0].to_string(),
			version: recipe_name_parts[1].to_string(),
			revision: revision,
			path: path.as_ref().display().to_string(),
			category: category,
			provide: provide.split("\n").map(str::to_string).collect(),
			depend: Dependencies {
				run: dep_run.split("\n").filter(|&x| !x.is_empty()).map(str::to_string).collect(),
				build: dep_build.split("\n").filter(|&x| !x.is_empty()).map(str::to_string).collect(),
				build_pre: dep_build_pre.split("\n").filter(|&x| !x.is_empty()).map(str::to_string).collect(),
			},
		})
	}

	/// Scrub over recipe fields and modify any found variable to the provided value
	pub fn set_vars(&mut self, key: String, new_value: String) {
		let templates = vec![format!("${}", key), format!("${{{}}}", key)];
		for template in templates.iter() {
			for value in self.provide.iter_mut() {
				if !value.contains(template) {
					continue
				}
				let new_string = value.replace(template, &new_value);
				let _ = mem::replace(value, new_string);
			}
			for value in self.depend.run.iter_mut() {
				if !value.contains(template) {
					continue
				}
				let new_string = value.replace(template, &new_value);
				let _ = mem::replace(value, new_string);
			}
			for value in self.depend.build.iter_mut() {
				if !value.contains(template) {
					continue
				}
				let new_string = value.replace(template, &new_value);
				let _ = mem::replace(value, new_string);
			}
			for value in self.depend.build_pre.iter_mut() {
				if !value.contains(template) {
					continue
				}
				let new_string = value.replace(template, &new_value);
				let _ = mem::replace(value, new_string);
			}
		}
	}
}

impl Display for Recipe {
	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
		write!(f, "Recipe {} / {}-{}-{}\n", self.category, self.name, self.version, self.revision)?;
		if self.provide.len() > 0 {
			write!(f, "  Specifies {} provides:\n", self.provide.len())?;
			for i in self.provide.iter() {
				write!(f, "    + {}\n", i)?;
			}
		}
		if self.depend.run.len() > 0 {
			write!(f, "  Specifies {} runtime dependencies:\n", self.depend.run.len())?;
			for i in self.depend.run.iter() {
				write!(f, "    + {}\n", i)?;
			}
		}
		if self.depend.build.len() > 0 {
			write!(f, "  Specifies {} build dependencies:\n", self.depend.build.len())?;
			for i in self.depend.build.iter() {
				write!(f, "    + {}\n", i)?;
			}
		}
		if self.depend.build_pre.len() > 0 {
			write!(f, "  Specifies {} pre-build dependencies:\n", self.depend.build_pre.len())?;
			for i in self.depend.build_pre.iter() {
				write!(f, "    + {}\n", i)?;
			}
		}
		Ok(())
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	static SAMPLE_RECIPE_A: &'static str = "sample/ports/games-emulation/dosbox/dosbox-0.74.3.recipe";
	static SAMPLE_RECIPE_B: &'static str = "sample/ports/media-fonts/anonymous_pro/anonymous_pro-1.002.001.recipe";

	#[test]
	/// Test creating a new Recipe from a file
	fn test_recipe_new_from_file() {
		let _recipeA = Recipe::new_from_file(SAMPLE_RECIPE_A, "games-emulation".to_string());
		let _recipeB = Recipe::new_from_file(SAMPLE_RECIPE_B, "media-fonts".to_string());
	}
	#[test]
	/// Test pulling a single-line field from a recipe file
	fn test_recipe_simple_field() {
		let revision = field_from_recipe(SAMPLE_RECIPE_A.into(), "REVISION".to_string());
		assert!(revision.unwrap() == "1", "Incorrect REVISION from sample recipe!");
	}
}