use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::Command;
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::error::{Error, Result};
use crate::plan::{Node, Plan};
use crate::refusal::Refusal;
pub const BINARY_ENV: &str = "ONETASKGRAPH_BIN";
pub const DEFAULT_BINARY: &str = "onetaskgraph";
const CHECKED_MINIMUM: Version = Version {
major: 0,
minor: 1,
patch: 0,
release: Release::Released,
};
pub const FIRST_REVISION: &str = "d8051ac20140e45b0b9f1747545e5a6ce7e6df5e";
pub const INSTALL: &str = "install it with `cargo install onetaskgraph`, \
`uv tool install onetaskgraph-cli`, or `npm install -g onetaskgraph-cli`, \
or set ONETASKGRAPH_BIN to an executable one";
const RESERVED: &str = "onepipeline.";
const ID_KEY: &str = "onepipeline.id";
pub(crate) const SETTLEMENT_KEY: &str = "onepipeline.settlement";
const FILLED_FROM_THE_TASK: &[(&str, &str)] = &[
("title", "the task's own `title`"),
("task", "the task's own `content`"),
];
const REPO_KEY: &str = "onepipeline.repo";
const TASKS_ARE_THE_PROJECTS: &str =
"`onepipeline.tasks` is not a project field: the plan's nodes are the project's own tasks";
const DEPS_ARE_EDGES: &str =
"`onepipeline.deps` carries cross-DAG `run:<id>#<node>` references only; a dependency \
on another node of this plan is a dependency edge between the two tasks";
#[derive(Debug, Clone)]
pub struct Store {
binary: PathBuf,
}
impl Store {
pub(crate) fn binary(&self) -> PathBuf {
self.binary.clone()
}
pub fn resolve() -> Result<Self> {
let binary = resolved_binary();
let named = |what: String| Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"{} ({what}); onepipeline reads a plan out of a onetaskgraph project and \
needs {DEFAULT_BINARY} {CHECKED_MINIMUM} or newer — {INSTALL}. The reserved \
metadata this mapping reads landed at revision {FIRST_REVISION}, after \
that product's 0.1.0 release, so an install older than that revision \
reports a version this check accepts and then answers with tasks \
carrying no metadata at all",
binary.display()
),
};
let reported = Command::new(&binary)
.arg("--version")
.env_remove(BINARY_ENV)
.output()
.map_err(|error| named(format!("cannot be run: {error}")))?;
if !reported.status.success() {
return Err(named(format!(
"refused `--version`: {}",
first_line(&reported.stderr)
.or_else(|| first_line(&reported.stdout))
.unwrap_or_else(|| format!("exit {}", code_of(&reported.status)))
)));
}
let printed = std::str::from_utf8(&reported.stdout)
.map_err(|error| named(format!("reported a version that is not UTF-8: {error}")))?;
let token = version_token(printed).unwrap_or_default();
let version = Version::parse(token).ok_or_else(|| {
named(format!(
"reported no version this build can read: {:?}",
printed.trim()
))
})?;
if version < CHECKED_MINIMUM {
return Err(named(format!("is version {token}, below the minimum")));
}
Ok(Self { binary })
}
pub fn plan(&self, project: &QualifiedId) -> Result<Plan> {
self.load(project)
.map(|read| read.plan)
.map_err(Error::from)
}
pub(crate) fn read_plan(&self, project: &QualifiedId) -> std::result::Result<Read, Load> {
self.load(project)
}
fn load(&self, project: &QualifiedId) -> std::result::Result<Read, Load> {
let held = self.project(project)?;
let tasks = self.tasks(project)?;
let mut document = Map::new();
for (key, value) in &held.item.metadata {
let Some(field) = key.strip_prefix(RESERVED) else {
continue;
};
if field == "tasks" {
return Err(
refused_plan(project.as_str(), TASKS_ARE_THE_PROJECTS.to_owned())
.field("tasks")
.into(),
);
}
document.insert(field.to_owned(), value.clone());
}
document
.entry("name".to_owned())
.or_insert_with(|| Value::String(held.item.title.clone()));
let mut ids: Ids = BTreeMap::new();
let mut claimed: BTreeMap<String, String> = BTreeMap::new();
for task in &tasks {
let id = node_id(&task.item)
.map_err(|why| refused(task.id.as_str(), format!("it {why}")).field("id"))?;
if let Some(first) = claimed.insert(id.clone(), task.id.to_string()) {
return Err(refused(
task.id.as_str(),
format!("`{ID_KEY}` '{id}' is already the id of another task, '{first}'"),
)
.field("id")
.into());
}
ids.insert(task.id.as_str().to_owned(), id);
}
let mut nodes = Vec::with_capacity(tasks.len());
for task in &tasks {
nodes.push(self.node(task, &ids)?);
}
document.insert("tasks".to_owned(), Value::Array(nodes));
let document = Value::Object(document);
if let Some(retired) = crate::plan::retired_field_refusal(&document) {
return Err(refused_plan(project.as_str(), retired).into());
}
for (task, node) in tasks.iter().zip(
document["tasks"]
.as_array()
.expect("the nodes just written"),
) {
read::<Node>(node.clone()).map_err(|error| {
refused(
task.id.as_str(),
format!(
"{error} — a node's fields are the reserved `{RESERVED}<field>` \
metadata keys on its task"
),
)
})?;
}
let plan: Plan = read::<Plan>(document).map_err(|error| {
refused_plan(
project.as_str(),
format!(
"{error} — a plan's settings are the reserved `{RESERVED}<field>` \
metadata keys on its project"
),
)
})?;
let metadata = plan
.tasks
.iter()
.map(|node| node.id.clone())
.zip(tasks.iter().map(|task| task.item.metadata.clone()))
.collect();
Ok(Read { plan, metadata })
}
fn node(&self, task: &Qualified<TaskItem>, ids: &Ids) -> std::result::Result<Value, Load> {
let mut node = Map::new();
for (key, value) in &task.item.metadata {
if key == SETTLEMENT_KEY {
continue;
}
let Some(field) = key.strip_prefix(RESERVED) else {
continue;
};
if let Some((_, whence)) = FILLED_FROM_THE_TASK
.iter()
.find(|(filled, _)| *filled == field)
{
return Err(refused(
task.id.as_str(),
format!(
"`{RESERVED}{field}` is not a node field: a node's `{field}` is {whence}"
),
)
.field(field)
.into());
}
node.insert(field.to_owned(), value.clone());
}
if !task.item.title.trim().is_empty() {
node.insert("title".to_owned(), Value::String(task.item.title.clone()));
}
if let Some(content) = task.item.content.as_ref().filter(|c| !c.trim().is_empty()) {
node.insert("task".to_owned(), Value::String(content.clone()));
}
match (task.item.repositories.first(), node.get("repo")) {
(Some(_), Some(_)) => {
return Err(refused(
task.id.as_str(),
format!(
"it names a repository in both `repositories` and `{REPO_KEY}`; a node \
lands in one repository, and `{REPO_KEY}` is only for an identity a \
normalized origin cannot hold"
),
)
.field("repo")
.into())
}
(Some(repository), None) => {
node.insert(
"repo".to_owned(),
Value::String(repository.as_str().to_owned()),
);
}
(None, _) => {}
}
let mut deps = self.deps(task, ids)?;
if let Some(carried) = node.remove("deps") {
let listed: Vec<String> = serde_json::from_value(carried).map_err(|error| {
refused(task.id.as_str(), format!("`{RESERVED}deps` {error}")).field("deps")
})?;
for reference in listed {
if !reference.starts_with(crate::crossdag::PREFIX) {
return Err(refused(
task.id.as_str(),
format!("`{RESERVED}deps` names '{reference}': {DEPS_ARE_EDGES}"),
)
.field("deps")
.into());
}
deps.push(reference);
}
}
if !deps.is_empty() {
node.insert(
"deps".to_owned(),
Value::Array(deps.into_iter().map(Value::String).collect()),
);
}
Ok(Value::Object(node))
}
fn deps(
&self,
task: &Qualified<TaskItem>,
ids: &Ids,
) -> std::result::Result<Vec<String>, Load> {
let mut deps = Vec::new();
for edge in self.edges(&task.id)? {
if edge.from.kind != ItemKind::Task {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"asked for the dependencies of task '{}' and answered with an edge from \
a {}",
task.id, edge.from.kind
),
}
.into());
}
if edge.from.id != task.id {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"asked for the dependencies of '{}' and answered with an edge from \
'{}'",
task.id, edge.from.id
),
}
.into());
}
if edge.kind != DependencyKind::Blocks {
continue;
}
let far = &edge.to;
let both = format!("'{}' depends on '{}'", task.id, far.id);
if far.kind != ItemKind::Task {
return Err(refused(
task.id.as_str(),
format!("{both}, which is a {} and not a node of a plan", far.kind),
)
.field("deps")
.into());
}
let resolved = match ids.get(far.id.as_str()) {
Some(id) => id.clone(),
None => {
let far_task = self.show(&far.id).map_err(|error| {
refused(
task.id.as_str(),
format!("{both}, which could not be read: {error}"),
)
.field("deps")
})?;
node_id(&far_task.item).map_err(|why| {
refused(task.id.as_str(), format!("{both}, and the far task {why}"))
.field("deps")
})?
}
};
deps.push(resolved);
}
Ok(deps)
}
fn project(&self, project: &QualifiedId) -> Result<Qualified<ProjectItem>> {
let read = self.read::<Qualified<ProjectItem>>(&["project", "show", project.as_str()])?;
one(project, read)
}
fn show(&self, task: &QualifiedId) -> Result<Qualified<TaskItem>> {
let read = self.read::<Qualified<TaskItem>>(&["task", "show", task.as_str()])?;
one(task, read)
}
fn tasks(&self, project: &QualifiedId) -> Result<Vec<Qualified<TaskItem>>> {
let tasks: Vec<Qualified<TaskItem>> =
self.paged(&["task", "list", "--project", project.as_str()])?;
for task in &tasks {
let elsewhere = match &task.item.project {
_ if task.id.source() != project.source() => Some("an item of another source"),
Some(named) if named != project.native() => Some("a task of another project"),
None => Some("a task of no project at all"),
Some(_) => None,
};
if let Some(elsewhere) = elsewhere {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"asked for the tasks of '{project}' and answered with '{}', which is \
{elsewhere}",
task.id
),
});
}
}
Ok(tasks)
}
fn edges(&self, task: &QualifiedId) -> Result<Vec<Edge>> {
self.paged(&["task", "deps", task.as_str(), "--direction", "depends-on"])
}
fn paged<T: serde::de::DeserializeOwned>(&self, args: &[&str]) -> Result<Vec<T>> {
let mut all = Vec::new();
let mut page: Option<String> = None;
let mut walked: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
loop {
let mut args = args.to_vec();
if let Some(token) = page.as_deref() {
args.extend_from_slice(&["--page", token]);
}
let response = self.read::<T>(&args)?;
all.extend(response.items);
let Some(token) = response.next else {
return Ok(all);
};
if token.is_empty() || !walked.insert(token.clone()) {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"`{DEFAULT_BINARY} {}` answered with a continuation token that does \
not advance the walk, so the whole project can never be read",
args.join(" ")
),
});
}
page = Some(token);
}
}
fn read<T: serde::de::DeserializeOwned>(&self, args: &[&str]) -> Result<Response<T>> {
let output = Command::new(&self.binary)
.args(args)
.arg("--json")
.env_remove(BINARY_ENV)
.output()
.map_err(|error| Error::Sibling {
tool: DEFAULT_BINARY,
message: format!("{} cannot be run: {error}", self.binary.display()),
})?;
if !output.status.success() {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"`{DEFAULT_BINARY} {}` exited {}: {}",
args.join(" "),
code_of(&output.status),
String::from_utf8_lossy(&output.stderr).trim()
),
});
}
serde_json::from_slice(&output.stdout).map_err(|error| Error::Sibling {
tool: DEFAULT_BINARY,
message: format!(
"`{DEFAULT_BINARY} {}` answered with something this build cannot read: {error}",
args.join(" ")
),
})
}
}
type Ids = BTreeMap<String, String>;
fn node_id(task: &TaskItem) -> std::result::Result<String, String> {
match task.metadata.get(ID_KEY) {
Some(Value::String(id)) if !id.trim().is_empty() => Ok(id.clone()),
Some(Value::String(_)) | None => Err(format!(
"carries no `{ID_KEY}`, which is the node id this plan's dependencies name it by"
)),
Some(other) => Err(format!(
"carries `{ID_KEY}` as {other}, and a node id is a string"
)),
}
}
fn read<T: serde::de::DeserializeOwned>(document: Value) -> std::result::Result<T, String> {
serde_path_to_error::deserialize(document).map_err(|error| match error.path().to_string() {
path if path == "." => error.into_inner().to_string(),
path => format!("{path}: {}", error.into_inner()),
})
}
fn refused(id: &str, what: String) -> Refusal {
Refusal::about(id, format!("{id}: {what}"))
}
fn refused_plan(id: &str, what: String) -> Refusal {
Refusal::plain(format!("{id}: {what}"))
}
pub(crate) struct Read {
pub plan: Plan,
pub metadata: BTreeMap<String, Map<String, Value>>,
}
pub(crate) enum Load {
Refused(Refusal),
Unreadable(Error),
}
impl From<Refusal> for Load {
fn from(refusal: Refusal) -> Self {
Self::Refused(refusal)
}
}
impl From<Error> for Load {
fn from(error: Error) -> Self {
Self::Unreadable(error)
}
}
impl From<Load> for Error {
fn from(load: Load) -> Self {
match load {
Load::Refused(refusal) => refusal.into(),
Load::Unreadable(error) => error,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "String")]
pub struct QualifiedId {
whole: String,
colon: usize,
}
impl QualifiedId {
pub fn source(&self) -> &str {
&self.whole[..self.colon]
}
pub fn native(&self) -> &str {
&self.whole[self.colon + 1..]
}
pub fn as_str(&self) -> &str {
&self.whole
}
}
impl TryFrom<String> for QualifiedId {
type Error = String;
fn try_from(whole: String) -> std::result::Result<Self, String> {
match whole.find(':') {
Some(colon) if valid_source_name(&whole[..colon]) && colon + 1 < whole.len() => {
Ok(Self { whole, colon })
}
_ => Err(format!(
"'{whole}' is not a qualified onetaskgraph id; write it as <source>:<native>, \
for example plan-store:ship-the-widget; source names use lower-case letters, \
digits and hyphens, starting with a letter or digit"
)),
}
}
}
fn valid_source_name(source: &str) -> bool {
let mut chars = source.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_lowercase() || first.is_ascii_digit())
&& chars.all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
})
}
impl std::str::FromStr for QualifiedId {
type Err = Error;
fn from_str(id: &str) -> Result<Self> {
Self::try_from(id.to_owned()).map_err(Error::Invalid)
}
}
impl std::fmt::Display for QualifiedId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.whole.fmt(formatter)
}
}
fn one<T>(id: &QualifiedId, response: Response<Qualified<T>>) -> Result<Qualified<T>> {
if response.next.is_some() {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!("asked to show '{id}' and the answer claims another page"),
});
}
let mut items = response.items.into_iter();
let Some(found) = items.next() else {
return Err(Error::Invalid(format!(
"'{id}' names nothing in the configured sources"
)));
};
if items.next().is_some() {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!("asked for '{id}' and answered with more than one item"),
});
}
if found.id != *id {
return Err(Error::Sibling {
tool: DEFAULT_BINARY,
message: format!("asked for '{id}' and answered with '{}'", found.id),
});
}
Ok(found)
}
fn first_line(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToOwned::to_owned)
}
fn code_of(status: &std::process::ExitStatus) -> String {
status
.code()
.map_or_else(|| "on a signal".to_owned(), |code| code.to_string())
}
fn resolved_binary() -> PathBuf {
match std::env::var_os(BINARY_ENV) {
Some(named) if named.to_str().is_some_and(|value| value.trim().is_empty()) => {
PathBuf::from(DEFAULT_BINARY)
}
Some(named) if !named.is_empty() => PathBuf::from(named),
_ => PathBuf::from(DEFAULT_BINARY),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Version {
major: u32,
minor: u32,
patch: u32,
release: Release,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Release {
Prerelease,
Released,
}
impl Version {
fn parse(token: &str) -> Option<Self> {
let token = token.strip_prefix('v').unwrap_or(token);
let (rest, build) = match token.split_once('+') {
Some((rest, build)) => (rest, Some(build)),
None => (token, None),
};
let (numbers, prerelease) = match rest.split_once('-') {
Some((numbers, prerelease)) => (numbers, Some(prerelease)),
None => (rest, None),
};
if prerelease.is_some_and(|value| !identifiers(value, true))
|| build.is_some_and(|value| !identifiers(value, false))
{
return None;
}
let number = |component: &str| {
(!(component.len() > 1 && component.starts_with('0')))
.then(|| component.parse::<u32>().ok())
.flatten()
};
let mut parts = numbers.split('.');
let major = number(parts.next()?)?;
let minor = number(parts.next().unwrap_or("0"))?;
let patch = number(parts.next().unwrap_or("0"))?;
if parts.next().is_some() {
return None;
}
Some(Self {
major,
minor,
patch,
release: match prerelease {
None => Release::Released,
Some(_) => Release::Prerelease,
},
})
}
}
impl std::fmt::Display for Version {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch)?;
match self.release {
Release::Prerelease => formatter.write_str("-<pre-release>"),
Release::Released => Ok(()),
}
}
}
fn identifiers(text: &str, prerelease: bool) -> bool {
!text.is_empty()
&& text.split('.').all(|part| {
!part.is_empty()
&& !(prerelease
&& part.len() > 1
&& part.starts_with('0')
&& part.chars().all(|character| character.is_ascii_digit()))
&& part
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
})
}
fn version_token(printed: &str) -> Option<&str> {
let line = printed.lines().find(|line| !line.trim().is_empty())?;
let mut tokens = line.split_whitespace();
let first = tokens.next()?;
let token = tokens.next().unwrap_or(first);
tokens.next().is_none().then_some(token)
}
#[derive(Debug, Deserialize)]
struct Response<T> {
items: Vec<T>,
#[serde(default)]
next: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Qualified<T> {
id: QualifiedId,
item: T,
}
#[derive(Debug, Deserialize)]
struct ProjectItem {
title: String,
#[serde(default)]
metadata: Map<String, Value>,
}
#[derive(Debug, Deserialize)]
struct TaskItem {
title: String,
#[serde(default)]
project: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
metadata: Map<String, Value>,
#[serde(default)]
repositories: Vec<Repository>,
}
#[derive(Debug, Deserialize)]
#[serde(try_from = "String")]
struct Repository(String);
impl Repository {
fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Repository {
type Error = String;
fn try_from(origin: String) -> std::result::Result<Self, Self::Error> {
let valid = !origin.is_empty()
&& !origin.contains("://")
&& !origin.ends_with(".git")
&& !origin.chars().any(char::is_whitespace)
&& origin.split('/').count() >= 3
&& origin
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..");
valid.then_some(Self(origin.clone())).ok_or_else(|| {
format!(
"{origin:?} is not a normalized repository origin; use host/owner/name without \
a scheme or .git suffix"
)
})
}
}
#[derive(Debug, Deserialize)]
struct Edge {
from: Endpoint,
to: Endpoint,
kind: DependencyKind,
}
#[derive(Debug, Deserialize)]
struct Endpoint {
id: QualifiedId,
kind: ItemKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum DependencyKind {
Blocks,
Related,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(from = "String")]
enum ItemKind {
Task,
Project,
Unknown(String),
}
impl From<String> for ItemKind {
fn from(wire: String) -> Self {
match wire.as_str() {
"task" => Self::Task,
"project" => Self::Project,
_ => Self::Unknown(wire),
}
}
}
impl std::fmt::Display for ItemKind {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Task => formatter.write_str("task"),
Self::Project => formatter.write_str("project"),
Self::Unknown(wire) => write!(formatter, "'{wire}'"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn version(printed: &str) -> Option<Version> {
Version::parse(version_token(printed)?)
}
#[test]
fn a_version_is_read_off_the_first_line_the_binary_prints() {
assert_eq!(version("onetaskgraph 0.1.0\n"), Version::parse("0.1.0"));
assert_eq!(version("onetaskgraph v1.2.3"), Version::parse("1.2.3"));
assert_eq!(version("2"), Version::parse("2.0.0"));
assert_eq!(version("onetaskgraph 0.1.0 (abc1234)"), None);
assert_eq!(version("\nonetaskgraph 0.1.0"), Version::parse("0.1.0"));
assert_eq!(version(""), None);
assert_eq!(version("onetaskgraph what"), None);
assert_eq!(version("onetaskgraph 1.2.3.4"), None);
assert_eq!(Version::parse("vv1.2.3"), None);
assert_eq!(Version::parse("1.2.3-"), None);
assert_eq!(Version::parse("1.2.3+"), None);
assert_eq!(Version::parse("1.2.3-rc..1"), None);
assert_eq!(Version::parse("1.2.3-rc 1"), None);
assert_eq!(Version::parse("1.2.3+build_7"), None);
assert_eq!(Version::parse("01.2.3"), None);
assert_eq!(Version::parse("1.02.3"), None);
assert_eq!(Version::parse("1.2.3-01"), None);
assert!(Version::parse("1.2.3-rc.01").is_none());
assert!(Version::parse("1.2.3+01").is_some());
assert!(Version::parse("1.2.3-rc.1+build-7").is_some());
}
#[test]
fn versions_order_by_each_number_in_turn_and_a_prerelease_below_its_release() {
let read = |token: &str| Version::parse(token).expect("a version");
assert!(read("0.0.9") < CHECKED_MINIMUM);
assert!(read("0.1.0") >= CHECKED_MINIMUM);
assert!(read("1.0.0") > CHECKED_MINIMUM);
assert!(read("0.1.0-rc.1") < CHECKED_MINIMUM);
assert!(read("0.1.1-rc.1") > CHECKED_MINIMUM);
assert_eq!(read("0.1.0+build.7"), CHECKED_MINIMUM);
}
#[test]
fn a_qualified_id_is_split_on_its_first_colon_and_a_bare_one_is_refused() {
let id: QualifiedId = "plan-store:ship".parse().expect("a qualified id");
assert_eq!(id.source(), "plan-store");
assert_eq!(id.native(), "ship");
assert_eq!(id.as_str(), "plan-store:ship");
let nested: QualifiedId = "plan-store:a:b".parse().expect("a qualified id");
assert_eq!(nested.native(), "a:b");
let message = "ship".parse::<QualifiedId>().unwrap_err().to_string();
assert!(message.contains("<source>:<native>"), "{message}");
assert!(":ship".parse::<QualifiedId>().is_err());
assert!("plan-store:".parse::<QualifiedId>().is_err());
assert!("Plan Store:ship".parse::<QualifiedId>().is_err());
assert!("plan_store:ship".parse::<QualifiedId>().is_err());
assert!("plan-store:ship it".parse::<QualifiedId>().is_ok());
assert!(serde_json::from_str::<QualifiedId>("\"bare\"").is_err());
}
#[test]
fn repository_origins_are_validated_when_the_store_response_is_read() {
assert!(serde_json::from_str::<Repository>("\"github.com/acme/widget\"").is_ok());
for invalid in [
"https://github.com/acme/widget",
"github.com/acme/widget.git",
"github.com/acme",
"github.com/acme/white space",
"github.com/acme/../widget",
] {
let message = serde_json::from_str::<Repository>(&format!("{invalid:?}"))
.unwrap_err()
.to_string();
assert!(
message.contains("normalized repository origin"),
"{message}"
);
}
}
#[test]
fn the_binary_is_the_environments_when_it_names_one_and_the_name_on_the_path_otherwise() {
let named = std::path::Path::new("/opt/onetaskgraph/bin/onetaskgraph");
std::env::set_var(BINARY_ENV, named);
assert_eq!(resolved_binary(), named);
std::env::set_var(BINARY_ENV, " ");
assert_eq!(resolved_binary(), PathBuf::from(DEFAULT_BINARY));
std::env::remove_var(BINARY_ENV);
assert_eq!(resolved_binary(), PathBuf::from(DEFAULT_BINARY));
}
#[test]
fn the_revision_the_checks_install_is_read_out_of_this_file() {
let justfile = include_str!("../justfile");
assert!(
justfile.contains("FIRST_REVISION"),
"the justfile no longer derives the revision it installs from this file"
);
assert!(
!justfile.contains(FIRST_REVISION),
"the justfile carries its own copy of the revision, which can go stale \
against the floor declared beside it here"
);
}
#[test]
fn a_task_carrying_no_node_id_is_refused_by_the_key_it_is_missing() {
let bare = TaskItem {
title: "Build it".into(),
project: None,
content: None,
metadata: Map::new(),
repositories: Vec::new(),
};
let message = node_id(&bare).unwrap_err();
assert!(message.contains(ID_KEY), "{message}");
assert!(message.starts_with("carries no"), "{message}");
let mut typed = bare;
typed.metadata.insert(ID_KEY.to_owned(), Value::from(7));
let message = node_id(&typed).unwrap_err();
assert!(message.contains("a node id is a string"), "{message}");
}
}