Files
weatherreporter/internal/app/output.go

193 lines
5.6 KiB
Go

package app
import (
"fmt"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
outputName, err := planned.Resolved.OutputName()
if err != nil {
return "", err
}
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error {
for index := range plannedReports {
outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index])
if err != nil {
return err
}
plannedReports[index].OutputPath = outputPath
}
return nil
}
func resolveReportOutputPath(workingDir, override, configuredDir string, resolved report.Resolved) (string, error) {
outputName, err := resolved.OutputName()
if err != nil {
return "", err
}
if override != "" {
return resolveOutputPath(workingDir, override, outputName)
}
outputDir, err := resolveOutputDir(workingDir, configuredDir)
if err != nil {
return "", err
}
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) (string, error) {
directory := configuredDir
if override != "" {
directory = override
}
return resolveOutputDir(workingDir, directory)
}
func resolveComparisonOutputDirectory(workingDir, override, configuredDir, reportOutputName string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
if override != "" {
return resolveComparisonDirectoryPath(workingDir, override)
}
outputDir, err := resolveOutputDir(workingDir, configuredDir)
if err != nil {
return "", err
}
name, err := comparison.DefaultDirectoryName(reportOutputName)
if err != nil {
return "", err
}
return filepath.Join(outputDir, name), nil
}
func resolveComparisonDirectoryPath(workingDir, directory string) (string, error) {
if strings.TrimSpace(directory) == "" {
return "", fmt.Errorf("comparison output directory is required")
}
if !filepath.IsAbs(directory) {
directory = filepath.Join(workingDir, directory)
}
return filepath.Clean(directory), nil
}
func resolveOutputDir(workingDir, override string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
if override == "" {
return workingDir, nil
}
if strings.TrimSpace(override) == "" {
return "", fmt.Errorf("output directory is required")
}
directory := override
if !filepath.IsAbs(directory) {
directory = filepath.Join(workingDir, directory)
}
directory = filepath.Clean(directory)
if err := preflightOutputDirectory(directory); err != nil {
return "", err
}
return directory, nil
}
func preflightOutputDirectory(directory string) error {
info, err := os.Stat(directory)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("output directory %q is not a directory", directory)
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("inspect output directory %q: %w", directory, err)
}
// A missing directory is valid, but os.Stat also reports ErrNotExist for a
// dangling symlink. Walk to the first existing component so invalid links
// fail preflight instead of being discovered only during publication.
for component := directory; ; component = filepath.Dir(component) {
componentInfo, componentErr := os.Lstat(component)
if componentErr == nil {
if componentInfo.Mode()&os.ModeSymlink != 0 {
targetInfo, targetErr := os.Stat(component)
if targetErr != nil {
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, targetErr)
}
if !targetInfo.IsDir() {
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
}
return nil
}
if !componentInfo.IsDir() {
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
}
return nil
}
if !os.IsNotExist(componentErr) {
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, componentErr)
}
if filepath.Dir(component) == component {
return fmt.Errorf("inspect output directory %q: no existing directory ancestor", directory)
}
}
}
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
path := override
if path == "" {
path = defaultName
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
if !filepath.IsAbs(path) {
path = filepath.Join(workingDir, path)
}
return validateOutputPath(path)
}
func validateWorkingDir(workingDir string) (string, error) {
if strings.TrimSpace(workingDir) == "" {
return "", fmt.Errorf("working directory is required")
}
if !filepath.IsAbs(workingDir) {
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
}
return filepath.Clean(workingDir), nil
}
func validateOutputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return "", fmt.Errorf("final output path %q must be absolute", path)
}
if filepath.Dir(path) == path {
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
}
if err := fileutil.ValidateAtomicPath(path); err != nil {
return "", fmt.Errorf("validate final output path %q: %w", path, err)
}
return path, nil
}