Skip to content

Latest commit

 

History

851 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

#!/usr/bin/env -S deno run --node-modules-dir=false --allow-write --allow-read --allow-run=bash,git,cargo --allow-net=docs.rs:443,github.com:443 --allow-env --allow-sys --no-lock

// NOTE: Pin the versions of the packages because the script runs without a lock file
import {
  CargoMetadataSchema as CargoMetadataBaseSchema,
  CargoPackageSchema as CargoPackageBaseSchema,
  PackageMetadataSchema,
  ReadmeSchema,
  selectWorkspacePackages,
  z,
} from "./CargoMetadata.ts"
import * as zx from "npm:zx@8.3.2"
import { ProcessPromise, Shell } from "npm:zx@8.3.2"
import { assert, assertEquals } from "jsr:@std/assert@1.0.0"
import { dirname, join, relative } from "jsr:@std/path@1.1.4"
import { parse as parseToml } from "jsr:@std/toml@1.0.5"

const CargoUrlSchema = z.union([z.string().url(), z.literal("")]).nullable()
const OptionalUrlSchema = CargoUrlSchema.optional()

// Each package must define its own README.md. Cargo autodetects README.md if `package.readme` is omitted. So, `package.readme` must be omitted
const PackageManifestSchema = z.object({
  package: z.object({
    name: z.string().min(1),
    readme: z.undefined().optional(),
    metadata: PackageMetadataSchema,
  }),
})

const WorkspaceManifestSchema = z.object({
  workspace: z.object({
    package: z.object({
      description: z.undefined().optional(),
      readme: z.undefined().optional(),
      homepage: OptionalUrlSchema,
      repository: OptionalUrlSchema,
    }).passthrough().optional(),
    metadata: z.object({
      details: z.object({
        name: z.string().min(1).optional(),
        title: z.string().nullable().optional(),
        readme: ReadmeSchema,
      }).default({}),
    }).default({}),
  }).optional(),
  package: PackageManifestSchema.shape.package.optional(),
})

const CargoTargetSchema = z.object({
  name: z.string().min(1),
  kind: z.array(z.string()).min(1),
})

const CargoPackageSchema = CargoPackageBaseSchema.extend({
  description: z.string().nullable(),
  homepage: CargoUrlSchema,
  repository: CargoUrlSchema,
  license: z.string().nullable(),
  metadata: PackageMetadataSchema,
  targets: z.array(CargoTargetSchema).min(1),
})

const CargoMetadataSchema = CargoMetadataBaseSchema.extend({
  packages: z.array(CargoPackageSchema),
})

type CargoPackage = z.infer<typeof CargoPackageSchema>
type Badge = Record<"name" | "image" | "url", string>
type Section = Record<"title" | "body", string>

const badge = (name: string, image: string, url: string): Badge => ({ name, image, url })
const pushSection = (sections: Section[], title: string, body: string) => sections.push({ title, body })

// Nested sections not supported
const renderSection = ({ title, body }: Section) => `## ${title}\n\n${body}`

const renderNonEmptySections = (sections: Section[]) => sections.filter((value) => value.body).map(renderSection).join("\n\n")

const autogeneratedHeader = `
<!-- DO NOT EDIT -->
<!-- This file is automatically generated by README.ts. -->
<!-- Edit README.ts if you want to make changes. -->
`.trim()

const crateDocsPlaceholder = `
<!-- crate documentation start -->
<!-- crate documentation end -->
`.trim()

/**
 * Examples:
 *
 * `normalizeGitRemoteUrl("git@github.com:DenisGorbachev/rust-private-template.git") == "https://github.com/DenisGorbachev/rust-private-template"`
 * `normalizeGitRemoteUrl("https://github.com/DenisGorbachev/rust-private-template.git") == "https://github.com/DenisGorbachev/rust-private-template"`
 *
 * @param url
 */
const normalizeGitRemoteUrl = (url: string) => {
  const parseableUrl = url.replace(/^git@([^:]+):/, "ssh://git@$1/")
  if (!URL.canParse(parseableUrl)) return url
  const remote = new URL(parseableUrl)
  return remote.hostname === "github.com" ? `https://github.com${remote.pathname.replace(/\/$/, "").replace(/\.git$/, "")}` : url
}

const scriptDir = import.meta.dirname
if (!scriptDir) throw new Error("Cannot determine the current script dirname")

const $: Shell<false, ProcessPromise> = zx.$({ cwd: scriptDir })
const rootManifestPath = await Deno.realPath(join(scriptDir, "Cargo.toml"))

const parseTomlFile = async <Output>(path: string, schema: z.ZodType<Output>, errorMessage: string) => {
  try {
    return schema.parse(parseToml(await Deno.readTextFile(path)))
  } catch (cause) {
    throw new Error(`${errorMessage}: '${path}'`, { cause })
  }
}

const parsePackageManifest = async (cargoPackage: CargoPackage) => {
  const { manifest_path: manifestPath, name } = cargoPackage
  const manifest = await parseTomlFile(manifestPath, PackageManifestSchema, `Package '${name}' manifest is invalid`)
  assertEquals(manifest.package.name, name)
}

const [cargoMetadataOutput, originUrlOutput, workspaceManifest] = await Promise.all([
  $`cargo metadata --format-version 1 --no-deps --manifest-path ${rootManifestPath}`,
  $`git remote get-url origin`,
  parseTomlFile(rootManifestPath, WorkspaceManifestSchema, "Workspace manifest is invalid"),
])

const cargoMetadata = CargoMetadataSchema.parse(JSON.parse(cargoMetadataOutput.stdout))
const originUrl = normalizeGitRemoteUrl(originUrlOutput.stdout.trim())
const workspaceRoot = await Deno.realPath(cargoMetadata.workspace_root)
assertEquals(workspaceRoot, await Deno.realPath(scriptDir))

const workspacePackages = selectWorkspacePackages(cargoMetadata)
  .sort((left, right) => left.manifest_path.localeCompare(right.manifest_path))
assertEquals(workspacePackages.length, new Set(cargoMetadata.workspace_members).size)

await Promise.all(workspacePackages.map(parsePackageManifest))
const rootPackage = workspacePackages.find((cargoPackage) => cargoPackage.manifest_path === rootManifestPath)
const repository = workspaceManifest.workspace?.package?.repository ?? rootPackage?.repository
if (repository) assertEquals(originUrl, repository)
for (const { repository: packageRepository } of workspacePackages) if (packageRepository) assertEquals(packageRepository, originUrl)

const checkIsPublicGitHubRepo = async () => {
  if (!URL.canParse(originUrl) || new URL(originUrl).hostname !== "github.com") return false
  const response = await fetch(originUrl, { method: "GET" })
  if (response.status === 200) return true
  if (response.status === 404) return false
  throw new Error(`Unexpected response status while checking GitHub repo visibility: ${response.status} ${response.statusText}`)
}

const licenseNameFileMap: Record<string, string> = {
  "Apache-2.0": "LICENSE-APACHE",
  "MIT": "LICENSE-MIT",
}

const getLicenseFile = (name: string) => {
  const file = licenseNameFileMap[name]
  if (file === undefined) throw new Error(`licenseNameFileMap is missing the following key: \`${name}\``)
  return file
}

const renderMarkdownList = (items: string[]) => items.map((item) => `* ${item}`).join("\n")

const renderShellCode = (code: string) => `\`\`\`shell\n${code}\n\`\`\``

const markdownRelativePath = (from: string, to: string) => relative(from, to).replaceAll("\\", "/")

const packageReadmeLink = (fromDirectory: string, cargoPackage: CargoPackage) => `[\`${cargoPackage.name}\`](${markdownRelativePath(fromDirectory, join(dirname(cargoPackage.manifest_path), "README.md"))})`

const packageLinks = (fromDirectory: string, packages: CargoPackage[]) =>
  renderMarkdownList(
    packages.map((cargoPackage) => packageReadmeLink(fromDirectory, cargoPackage)),
  )

const temporaryPaths = new Set<string>()

type RenderedReadme = Record<"destinationPath" | "temporaryPath", string>

const writeTemporaryReadme = async (destinationPath: string, content: string) => {
  const temporaryPath = await Deno.makeTempFile({
    dir: dirname(destinationPath),
    prefix: ".README.",
    suffix: ".tmp",
  })
  temporaryPaths.add(temporaryPath)
  await Deno.writeTextFile(temporaryPath, `${content}\n`)
  return temporaryPath
}

const renderPackageReadme = async (cargoPackage: CargoPackage, isPublicGitHubRepo: boolean): Promise<RenderedReadme> => {
  const { license, name, targets } = cargoPackage
  try {
    const packageDirectory = dirname(cargoPackage.manifest_path)
    const destinationPath = join(packageDirectory, "README.md")
    const title = cargoPackage.metadata.details.title?.trim() || name
    const peers = cargoPackage.metadata.details.peers
    const primaryTarget = targets.at(0)
    assert(primaryTarget, `Could not find primary target for package '${name}'`)
    const primaryBinTarget = targets.find((target) => target.kind.includes("bin"))
    const secondaryBinTargets = targets.filter(
      (target) => target !== primaryTarget && target !== primaryBinTarget && target.kind.includes("bin"),
    )
    const docsUrl = `https://docs.rs/${name}`
    const docsUrlPromise = fetch(docsUrl, { method: "HEAD" })
    const helpPromise = primaryBinTarget ? $`cargo run --quiet --manifest-path ${cargoPackage.manifest_path} --package ${name} --bin ${primaryBinTarget.name} -- --help` : undefined

    const docsUrlHead = await docsUrlPromise
    const badges: Badge[] = []
    if (isPublicGitHubRepo) badges.push(badge("Build", `${originUrl}/actions/workflows/ci.yml/badge.svg`, originUrl))
    if (docsUrlHead.status === 200) badges.push(badge("Documentation", `https://docs.rs/${name}/badge.svg`, docsUrl))
    const badgesText = badges.map(({ name: badgeName, image, url }) => `[![${badgeName}](${image})](${url})`).join("\n")
    const titleSectionBody = [badgesText, crateDocsPlaceholder].filter((value) => value.length > 0).join("\n\n")

    const sections: Section[] = []
    const installationSectionBodyParts: string[] = []
    const installationSectionUseExpandedFormat = primaryBinTarget && primaryTarget !== primaryBinTarget
    if (primaryBinTarget) {
      const command = renderShellCode(`cargo install --locked ${name}`)
      installationSectionBodyParts.push(installationSectionUseExpandedFormat ? `Install as executable:\n\n${command}` : command)
    }
    if (primaryTarget !== primaryBinTarget) {
      const command = renderShellCode(`cargo add ${[name, ...peers].join(" ")}`)
      installationSectionBodyParts.push(
        installationSectionUseExpandedFormat ? `Install as library dependency in your package:\n\n${command}` : command,
      )
    }
    pushSection(sections, "Installation", installationSectionBodyParts.join("\n\n"))

    if (helpPromise) {
      const help = await helpPromise
      pushSection(sections, "Usage", renderShellCode(help.stdout.trim()))
    }
    if (secondaryBinTargets.length > 0) {
      pushSection(
        sections,
        "Additional binaries",
        renderMarkdownList(secondaryBinTargets.map((target) => `\`${target.name}\``)),
      )
    }
    if (rootPackage?.id === cargoPackage.id) {
      const otherPackages = workspacePackages.filter((candidate) => candidate.id !== cargoPackage.id)
      const body = otherPackages.length > 0 ? packageLinks(packageDirectory, otherPackages) : "This workspace has no other packages."
      pushSection(sections, "Other packages", body)
    }
    if (isPublicGitHubRepo) pushSection(sections, "Gratitude", `Like the project? [⭐ Star this repo](${originUrl}) on GitHub!`)

    const licenseNames = license ? license.split("OR").map((licenseName) => licenseName.trim()) : []
    if (licenseNames.length > 0) {
      const licenseLinks = licenseNames.map((licenseName) => {
        const file = join(workspaceRoot, getLicenseFile(licenseName))
        return `[${licenseName}](${markdownRelativePath(packageDirectory, file)})`
      })
      pushSection(
        sections,
        "License",
        `
${licenseLinks.join(" or ")}.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, shall be licensed as above, without any additional terms or conditions.
`.trim(),
      )
    }

    const body = renderNonEmptySections(sections)
    const content = [autogeneratedHeader, `# ${title}`, titleSectionBody, body]
      .filter((value) => value.length > 0)
      .join("\n\n")
    const temporaryPath = await writeTemporaryReadme(destinationPath, content)
    await $`cargo insert-docs crate-into-readme --allow-dirty --link-to-latest --shrink-headings 0 --manifest-path ${cargoPackage.manifest_path} --package ${name} --readme-path ${temporaryPath}`
    await Deno.chmod(temporaryPath, 0o644)
    return { destinationPath, temporaryPath }
  } catch (cause) {
    throw new Error(`Failed to render README.md for package '${name}'`, { cause })
  }
}

const renderVirtualWorkspaceReadme = async (): Promise<RenderedReadme> => {
  try {
    const destinationPath = join(workspaceRoot, "README.md")
    const workspaceDetails = workspaceManifest.workspace?.metadata?.details
    const packageManifest = workspaceManifest.package
    const title = workspaceDetails?.title?.trim() || packageManifest?.metadata?.details?.title?.trim() || workspaceDetails?.name || packageManifest?.name
    assert(title, "The root manifest must define workspace.metadata.details.name or package.name")
    const content = [autogeneratedHeader, `# ${title}`, packageLinks(workspaceRoot, workspacePackages)].join("\n\n")
    const temporaryPath = await writeTemporaryReadme(destinationPath, content)
    await Deno.chmod(temporaryPath, 0o644)
    return { destinationPath, temporaryPath }
  } catch (cause) {
    throw new Error("Failed to render the virtual workspace README.md", { cause })
  }
}

/// PRUNING: Removes only temporary README render files after commit or failure because they contain no user-owned data.
const removeTemporaryReadme = async (path: string) => {
  try {
    await Deno.remove(path)
  } catch (error) {
    if (!(error instanceof Deno.errors.NotFound)) throw error
  }
}

/// PRUNING: Replaces obsolete generated README contents only after every replacement has rendered successfully.
const replaceReadme = async ({ destinationPath, temporaryPath }: RenderedReadme) => {
  await Deno.rename(temporaryPath, destinationPath)
  temporaryPaths.delete(temporaryPath)
}

try {
  const generateWorkspaceReadme = workspaceManifest.workspace?.metadata?.details?.readme?.generate ?? true
  const packagesToRender = workspacePackages.filter(({ metadata }) => metadata.details.readme.generate ?? generateWorkspaceReadme)
  const isPublicGitHubRepo = packagesToRender.length > 0 ? await checkIsPublicGitHubRepo() : false
  const renderPromises = packagesToRender.map((cargoPackage) => renderPackageReadme(cargoPackage, isPublicGitHubRepo))
  if (!rootPackage && generateWorkspaceReadme) renderPromises.push(renderVirtualWorkspaceReadme())

  const results = await Promise.allSettled(renderPromises)
  const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])
  if (failures.length > 0) throw new AggregateError(failures, `Failed to render ${failures.length} README target(s)`)

  const renderedReadmes = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : [])
  assertEquals(new Set(renderedReadmes.map((target) => target.destinationPath)).size, renderedReadmes.length)
  // There is a minor risk of partial README replacement failure. This is acceptable because files will be regenerated during the next successful run of fix:readme.
  await Promise.all(renderedReadmes.map(replaceReadme))
} finally {
  await Promise.all([...temporaryPaths].map(removeTemporaryReadme))
}

About

OneShot is an LLM driver for generating whole apps in one shot.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages