view src/org/tmatesoft/hg/core/HgUpdateConfigCommand.java @ 498:0205a5c4566b

Issue 38: preserve user formatting and comments when updating configuration files
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Fri, 26 Oct 2012 18:17:15 +0200
parents ba36f66c32b4
children
line wrap: on
line source
/*
 * Copyright (c) 2012 TMate Software Ltd
 *  
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * For information on how to redistribute this software under
 * the terms of a license other than GNU General Public License
 * contact TMate Software at support@hg4j.com
 */
package org.tmatesoft.hg.core;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import org.tmatesoft.hg.internal.ConfigFile;
import org.tmatesoft.hg.internal.ConfigFileParser;
import org.tmatesoft.hg.internal.Internals;
import org.tmatesoft.hg.repo.HgInternals;
import org.tmatesoft.hg.repo.HgInvalidStateException;
import org.tmatesoft.hg.repo.HgRepository;

/**
 * Command to alter Mercurial configuration settings at various levels (system-wide, user-wide, repository-wide).
 *
 * @author Artem Tikhomirov
 * @author TMate Software Ltd.
 */
public final class HgUpdateConfigCommand extends HgAbstractCommand<HgUpdateConfigCommand> {
	
	private final SessionContext sessionCtx;
	private final File configFile;
	
	private final List<Operation> changes = new LinkedList<Operation>();
	private boolean ignoreMissingKeys = false;

	private HgUpdateConfigCommand(SessionContext sessionContext, File configurationFile) {
		sessionCtx = sessionContext;
		configFile = configurationFile;
	}
	
	public static HgUpdateConfigCommand forRepository(HgRepository hgRepo) {
		return new HgUpdateConfigCommand(hgRepo.getSessionContext(), HgInternals.getImplementationRepo(hgRepo).getFileFromRepoDir("hgrc"));
	}
	
	public static HgUpdateConfigCommand forUser(SessionContext ctx) {
		return new HgUpdateConfigCommand(ctx, Internals.getUserConfigurationFileToWrite(ctx));
	}
	
	public static HgUpdateConfigCommand forInstallation(SessionContext ctx) {
		return new HgUpdateConfigCommand(ctx, Internals.getInstallationConfigurationFileToWrite(ctx));
	}

	/**
	 * Remove an entry altogether. If no entry with the key found, {@link #execute()} fails.
	 * 
	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
	 * @return <code>this</code> for convenience
	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
	 */
	public HgUpdateConfigCommand remove(String section, String key) throws IllegalArgumentException {
		checkSection(section);
		checkKey(key);
		changes.add(Operation.deleteEntry(section, key));
		return this;
	}

	/**
	 * Delete single attribute in a multi-valued property. If specified value not found among values of
	 * the identified entry, {@link #execute()} fails. 
	 * 
	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
	 * @param value one of the values to remove, not <code>null</code> or an empty value
	 * @return <code>this</code> for convenience
	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
	 */
	public HgUpdateConfigCommand remove(String section, String key, String value) throws IllegalArgumentException {
		checkSection(section);
		checkKey(key);
		changes.add(Operation.deleteValue(section, key, value));
		return this;
	}
	
	/**
	 * Set single-valued property or update multi-valued with a single value
	 * 
	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
	 * @param value new value, may be <code>null</code>
	 * @return <code>this</code> for convenience
	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
	 */
	public HgUpdateConfigCommand put(String section, String key, String value) throws IllegalArgumentException {
		checkSection(section);
		checkKey(key);
		changes.add(Operation.setValue(section, key, value));
		return this;
	}
	
	/**
	 * Add value to a multi-valued entry. If specified entry not found, {@link #execute()} fails.
	 * 
	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
	 * @param value new value to add, not <code>null</code> or an empty value
	 * @return <code>this</code> for convenience
	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
	 */
	public HgUpdateConfigCommand add(String section, String key, String value) throws IllegalArgumentException {
		checkSection(section);
		checkKey(key);
		changes.add(Operation.addValue(section, key, value));
		return this;
	}
	
	/**
	 * Tells whether {@link #execute()} shall fail with exception if keys selected for modification were not found. 
	 * If <code>true</code>, missing keys would be silently ignored. 
	 * When <code>false</code>(<em>default</em>), exception would be raised.
	 *  
	 * @param ignoreMissing pass <code>true</code> to ignore any incorrect keys
	 * @return <code>this</code> for convenience
	 */
	public HgUpdateConfigCommand ignoreMissing(boolean ignoreMissing) {
		ignoreMissingKeys = ignoreMissing;
		return this;
	}
	
	/**
	 * Perform configuration file update.
	 * 
	 * @throws HgMissingConfigElementException if attempt to alter an entry failed to find one, and missing keys are not ignored
	 * @throws HgIOException when configuration file read/write attemt has failed
	 * @throws HgException subclass thereof to indicate specific issue with the command arguments or repository state
	 */
	public void execute() throws HgMissingConfigElementException, HgIOException, HgException {
		try {
			ConfigFile cfgRead = new ConfigFile(sessionCtx);
			cfgRead.addLocation(configFile);
			ConfigFileParser cfgWrite = new ConfigFileParser();
			FileInputStream fis = new FileInputStream(configFile);
			cfgWrite.parse(fis);
			fis.close();
			for (Operation op : changes) {
				if (!ignoreMissingKeys && !cfgRead.hasSection(op.section)) {
					throw new HgMissingConfigElementException("Bad section name", op.section, op.key);
				}
				Map<String, String> sect = cfgRead.getSection(op.section);
				if (!ignoreMissingKeys && !sect.containsKey(op.key)) {
					throw new HgMissingConfigElementException("Bad key name", op.section, op.key);
				}
				String oldValue = sect.get(op.key);
				if (oldValue == null) {
					oldValue = "";
				}
				switch (op.kind) {
				case AddValue: {
					String separator = ", "; // XXX shall parse and find out separator kind in use
					String newValue = oldValue + separator + op.value;
					if (sect.containsKey(op.key)) {
						cfgWrite.change(op.section, op.key, newValue);
					} else {
						cfgWrite.add(op.section, op.key, newValue);
					}
					break;
				}
				case DelValue: {
					if (!ignoreMissingKeys && (oldValue.length() == 0 || !oldValue.contains(op.value))) {
						throw new HgMissingConfigElementException(String.format("Bad value '%s' to delete from '%s'", op.value, oldValue), op.section, op.key);
					}
					int start = oldValue.indexOf(op.value);
					if (start == -1) {
						// nothing to change
						break;
					}
					int commaPos = -1;
					for (int i = start-1; i >=0; i--) {
						if (oldValue.charAt(i) == ',') {
							commaPos = i;
							break;
						}
					}
					for (int i = start + op.value.length(); commaPos == -1 && i < oldValue.length(); i++) {
						if (oldValue.charAt(i) == ',') {
							commaPos = i;
							break;
						}
					}
					String newValue;
					if (commaPos >= 0) {
						if (commaPos < start) {
							// from preceding comma up to end of value
							newValue = oldValue.substring(0, commaPos) + oldValue.substring(start + op.value.length());
						} else {
							// from value start up to and including subsequent comma
							newValue = oldValue.substring(0, start) + oldValue.substring(commaPos+1);
						}
					} else {
						// found no separator, just remove the value
						// extra whitespaces (if space, not a comma is a separator) won't hurt
						newValue = oldValue.substring(0, start) + oldValue.substring(start + op.value.length());
					}
					cfgWrite.change(op.section, op.key, newValue);
					break;
				}
				case SetValue: {
					if (sect.containsKey(op.key)) {
						cfgWrite.change(op.section, op.key, op.value);
					} else {
						cfgWrite.add(op.section, op.key, op.value);
					}
					break;
				}
				case DelEntry: {
					cfgWrite.delete(op.section, op.key);
					break;
				}
				default: throw new HgInvalidStateException(String.format("Unknown change %s", op.kind));
				}
			}
			FileOutputStream fos = new FileOutputStream(configFile);
			cfgWrite.update(fos);
			fos.close();
		} catch (IOException ex) {
			String m = String.format("Failed to update configuration file %s", configFile);
			throw new HgBadArgumentException(m, ex); // TODO [post-1.0] better exception, it's not bad argument case
		}
	}
	
	private static void checkSection(String section) throws IllegalArgumentException {
		if (section == null || section.trim().length() == 0) {
			throw new IllegalArgumentException(String.format("Section name can't be empty: %s", section));
		}
	}
	
	private static void checkKey(String key) throws IllegalArgumentException {
		if (key == null || key.trim().length() == 0) {
			throw new IllegalArgumentException(String.format("Entry key can't be empty: %s", key));
		}
	}


	private static class Operation {
		private enum OpKind { AddValue, SetValue, DelValue, DelEntry };

		public final OpKind kind;
		public final String section;
		public final String key;
		public final String value;
		
		private Operation(OpKind t, String s, String k, String v) {
			kind = t;
			section = s;
			key = k;
			value = v;
		}
		
		public static Operation deleteEntry(String section, String key) throws IllegalArgumentException {
			return new Operation(OpKind.DelEntry, section, key, null);
		}
		public static Operation deleteValue(String section, String key, String value) throws IllegalArgumentException {
			if (value == null || value.trim().length() == 0) {
				throw new IllegalArgumentException(String.format("Can't remove empty value '%s'", value));
			}
			return new Operation(OpKind.DelValue, section, key, value);
		}
		public static Operation addValue(String section, String key, String value) throws IllegalArgumentException {
			if (value == null || value.trim().length() == 0) {
				throw new IllegalArgumentException(String.format("Can't add empty value '%s'", value));
			}
			return new Operation(OpKind.AddValue, section, key, value);
		}
		public static Operation setValue(String section, String key, String value) throws IllegalArgumentException {
			return new Operation(OpKind.SetValue, section, key, value);
		}
	}
}